diff --git a/api/package.json b/api/package.json
index d16e5b8..9a5d88c 100644
--- a/api/package.json
+++ b/api/package.json
@@ -6,7 +6,10 @@
"build": "tsc",
"start": "node dist/index.js",
"test": "mocha",
- "test:watch": "mocha --watch"
+ "test:watch": "mocha --watch",
+ "lint": "biome check .",
+ "lint:fix": "biome check --write .",
+ "format": "biome format --write ."
},
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
diff --git a/api/src/__tests__/README.md b/api/src/__tests__/README.md
new file mode 100644
index 0000000..fe5c1f1
--- /dev/null
+++ b/api/src/__tests__/README.md
@@ -0,0 +1,222 @@
+# API Test Suite
+
+This directory contains comprehensive tests for the XTablo API, covering all endpoints and their functionality.
+
+## Test Files
+
+### 1. `test-utils.ts`
+
+Provides testing utilities and mock factories:
+
+- **Mock Clients**: Supabase, Stream Chat, S3, Email Transporter
+- **Mock Data**: Users, Profiles, Tablos, Events
+- **Helper Functions**: Context creation, stub management, assertions
+- **Environment Setup**: Mock environment variables for tests
+
+### 2. `middleware.test.ts`
+
+Tests for API middleware:
+
+- **authMiddleware**: Bearer token authentication
+- **supabaseMiddleware**: Supabase client initialization
+- **streamChatMiddleware**: Stream Chat client initialization
+- **r2Middleware**: S3/R2 client initialization
+
+### 3. `user.test.ts`
+
+Tests for User Router (`/api/v1/users`):
+
+- **POST /sign-up-to-stream**: User registration with Stream Chat
+- **GET /me**: Retrieve user profile with Stream token
+- **POST /mark-temporary**: Mark user as temporary and send welcome email
+
+### 4. `tablo.test.ts`
+
+Tests for Tablo Router (`/api/v1/tablos`):
+
+- **POST /create**: Create new tablo with events
+- **POST /create-and-invite**: Create tablo and invite user
+- **PATCH /update**: Update tablo details
+- **DELETE /delete**: Soft delete tablo
+- **POST /invite**: Send tablo invitation
+- **POST /join**: Join tablo with invite token
+- **GET /members/:tablo_id**: Get tablo members
+- **POST /leave**: Leave a tablo
+- **POST /webcal/generate-url**: Generate webcal subscription URL
+
+### 5. `tablo_data.test.ts`
+
+Tests for Tablo Data Router (`/api/v1/tablo-data`):
+
+- **GET /:tabloId/filenames**: List files in tablo
+- **GET /:tabloId/:fileName**: Get file content
+- **POST /:tabloId/:fileName**: Upload/update file
+- **DELETE /:tabloId/:fileName**: Delete file
+
+### 6. `tasks.test.ts`
+
+Tests for Tasks Router (`/api/v1/tasks`):
+
+- **POST /sync-calendars**: Sync calendar subscriptions (with authentication)
+
+### 7. `public.test.ts`
+
+Tests for Public Router (`/api/public`):
+
+- **GET /slots/:shortUserId/:standardName**: Get available time slots for booking
+
+### 8. `helpers.test.ts`
+
+Tests for helper functions:
+
+- **generateICSFromEvents**: Generate ICS calendar files
+- **writeCalendarFileToR2**: Write calendar to R2 storage
+- **isTabloMember**: Check if user is tablo member
+- **isTabloAdmin**: Check if user is tablo admin
+- **getTabloFileNames**: Get list of files in tablo
+
+### 9. `slots.test.ts`
+
+Tests for slot generation logic (existing):
+
+- Time slot generation with various configurations
+- Exception handling
+- Event conflicts
+- Buffer time
+- Minimum advance booking
+- Maximum bookings per day
+
+## Running Tests
+
+### Run all tests:
+
+```bash
+npm test
+```
+
+### Run tests in watch mode:
+
+```bash
+npm run test:watch
+```
+
+### Run specific test file:
+
+```bash
+npx mocha src/__tests__/user.test.ts
+```
+
+## Test Coverage
+
+The test suite covers:
+
+1. **Authentication & Authorization**
+
+ - Token validation
+ - User authentication
+ - Admin/member access control
+
+2. **CRUD Operations**
+
+ - Create, read, update, delete for all entities
+ - Soft deletes
+ - Batch operations
+
+3. **Business Logic**
+
+ - Tablo invitations and access control
+ - Calendar generation and synchronization
+ - File storage and retrieval
+ - Time slot availability calculation
+
+4. **Error Handling**
+
+ - Missing required fields
+ - Invalid tokens
+ - Permission denied scenarios
+ - Database errors
+ - External service failures (S3, Stream Chat)
+
+5. **Integration Points**
+ - Supabase database operations
+ - Stream Chat channel management
+ - R2/S3 file operations
+ - Email sending
+
+## Testing Framework
+
+- **Test Runner**: Mocha
+- **Assertions**: Chai
+- **Mocking**: Sinon
+- **Test Style**: BDD (Behavior Driven Development)
+
+## Test Structure
+
+Each test file follows this structure:
+
+```typescript
+describe("Feature/Router Name", () => {
+ beforeEach(() => {
+ // Setup mocks and environment
+ });
+
+ afterEach(() => {
+ // Clean up stubs and restore environment
+ });
+
+ describe("Endpoint/Function Name", () => {
+ it("should handle success case", async () => {
+ // Arrange: Setup test data and mocks
+ // Act: Execute the function/endpoint
+ // Assert: Verify the results
+ });
+
+ it("should handle error case", async () => {
+ // Test error scenarios
+ });
+ });
+});
+```
+
+## Mock Strategy
+
+Tests use comprehensive mocking to isolate units under test:
+
+1. **Supabase Client**: Mocked query builder pattern
+2. **Stream Chat**: Mocked channel operations
+3. **S3 Client**: Mocked storage operations
+4. **Email Transporter**: Mocked email sending
+
+This ensures tests run quickly and don't depend on external services.
+
+## Best Practices
+
+1. **Isolation**: Each test is independent and doesn't affect others
+2. **Clarity**: Test names clearly describe what is being tested
+3. **Coverage**: Both happy paths and error cases are tested
+4. **Maintainability**: Shared utilities reduce code duplication
+5. **Speed**: Mocking ensures tests run in milliseconds
+
+## Future Improvements
+
+- Integration tests with real database
+- End-to-end API tests
+- Performance benchmarks
+- Load testing
+- Code coverage reporting
+
+## Contributing
+
+When adding new endpoints or functionality:
+
+1. Create tests first (TDD approach recommended)
+2. Follow existing test patterns
+3. Mock external dependencies
+4. Test both success and failure scenarios
+5. Ensure tests pass before committing
+
+## Notes
+
+- Some lint warnings for `any` types are suppressed with `biome-ignore` comments - these are intentional for test flexibility
+- Mock data is defined in `test-utils.ts` for consistency
+- Environment variables are mocked in each test file's `beforeEach` hook
diff --git a/api/src/__tests__/helpers.test.ts b/api/src/__tests__/helpers.test.ts
new file mode 100644
index 0000000..85274d6
--- /dev/null
+++ b/api/src/__tests__/helpers.test.ts
@@ -0,0 +1,426 @@
+import { expect } from "chai";
+import { afterEach, beforeEach, describe, it } from "mocha";
+import sinon from "sinon";
+import {
+ generateICSFromEvents,
+ getTabloFileNames,
+ isTabloAdmin,
+ isTabloMember,
+ writeCalendarFileToR2,
+} from "../helpers.js";
+import type { EventAndTablo } from "../types.js";
+import {
+ createMockS3Client,
+ createMockSupabaseClient,
+ mockEnvVars,
+ mockEvent,
+ mockTablo,
+ mockUser,
+} from "./test-utils.js";
+
+describe("Helper Functions", () => {
+ // biome-ignore lint/suspicious/noExplicitAny: Mock client types
+ let mockSupabase: any;
+ // biome-ignore lint/suspicious/noExplicitAny: Mock client types
+ let mockS3: any;
+ let restoreEnv: () => void;
+
+ beforeEach(() => {
+ restoreEnv = mockEnvVars();
+ mockSupabase = createMockSupabaseClient();
+ mockS3 = createMockS3Client();
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ restoreEnv();
+ });
+
+ describe("generateICSFromEvents", () => {
+ it("should generate valid ICS content from events", () => {
+ const events: EventAndTablo[] = [
+ {
+ event_id: "event1",
+ tablo_id: "tablo1",
+ tablo_name: "Test Tablo",
+ tablo_color: "bg-blue-500",
+ tablo_status: "todo",
+ title: "Test Event",
+ description: "Test description",
+ start_date: "2024-01-16",
+ start_time: "10:00:00",
+ end_time: "11:00:00",
+ // created_by: mockUser.id,
+ // created_at: "2024-01-01T00:00:00Z",
+ // deleted_at: null,
+ },
+ ];
+
+ const icsContent = generateICSFromEvents(events, "Test Calendar");
+
+ expect(icsContent).to.include("BEGIN:VCALENDAR");
+ expect(icsContent).to.include("VERSION:2.0");
+ expect(icsContent).to.include("X-WR-CALNAME:Test Calendar");
+ expect(icsContent).to.include("BEGIN:VEVENT");
+ expect(icsContent).to.include("SUMMARY:Test Event");
+ expect(icsContent).to.include("DESCRIPTION:Tablo: Test Tablo");
+ expect(icsContent).to.include("END:VEVENT");
+ expect(icsContent).to.include("END:VCALENDAR");
+ });
+
+ it("should handle events without end_time", () => {
+ const events: EventAndTablo[] = [
+ {
+ event_id: "event1",
+ tablo_id: "tablo1",
+ tablo_name: "Test Tablo",
+ tablo_color: "bg-blue-500",
+ tablo_status: "todo",
+ title: "Test Event",
+ description: null,
+ start_date: "2024-01-16",
+ start_time: "10:00:00",
+ end_time: null,
+ created_by: mockUser.id,
+ created_at: "2024-01-01T00:00:00Z",
+ deleted_at: null,
+ // biome-ignore lint/suspicious/noExplicitAny: Mock event with null end_time
+ } as any,
+ ];
+
+ const icsContent = generateICSFromEvents(events, "Test Calendar");
+
+ expect(icsContent).to.include("BEGIN:VEVENT");
+ expect(icsContent).to.include("SUMMARY:Test Event");
+ expect(icsContent).to.include("END:VEVENT");
+ });
+
+ it("should escape special characters in ICS text", () => {
+ const events: EventAndTablo[] = [
+ {
+ event_id: "event1",
+ tablo_id: "tablo1",
+ tablo_name: "Test; Tablo,",
+ tablo_color: "bg-blue-500",
+ tablo_status: "todo",
+ title: "Test; Event,",
+ description: "Test\\description\nwith newline",
+ start_date: "2024-01-16",
+ start_time: "10:00:00",
+ end_time: "11:00:00",
+ // created_by: mockUser.id,
+ // created_at: "2024-01-01T00:00:00Z",
+ // deleted_at: null,
+ },
+ ];
+
+ const icsContent = generateICSFromEvents(events, "Test Calendar");
+
+ expect(icsContent).to.include("SUMMARY:Test\\; Event\\,");
+ expect(icsContent).to.include(
+ "DESCRIPTION:Tablo: Test\\; Tablo\\,\\nTest\\\\description\\nwith newline"
+ );
+ });
+
+ it("should skip events without required fields", () => {
+ const events: EventAndTablo[] = [
+ {
+ event_id: "event1",
+ tablo_id: "tablo1",
+ tablo_name: "Test Tablo",
+ tablo_color: "bg-blue-500",
+ tablo_status: "todo",
+ // biome-ignore lint/suspicious/noExplicitAny: Testing null title case
+ title: null as any,
+ description: null,
+ start_date: "2024-01-16",
+ start_time: "10:00:00",
+ end_time: "11:00:00",
+ // created_by: mockUser.id,
+ // created_at: "2024-01-01T00:00:00Z",
+ // deleted_at: null,
+ },
+ ];
+
+ const icsContent = generateICSFromEvents(events, "Test Calendar");
+
+ expect(icsContent).to.include("BEGIN:VCALENDAR");
+ expect(icsContent).to.not.include("BEGIN:VEVENT");
+ expect(icsContent).to.include("END:VCALENDAR");
+ });
+
+ it("should handle multiple events", () => {
+ const events: EventAndTablo[] = [
+ {
+ event_id: "event1",
+ tablo_id: "tablo1",
+ tablo_name: "Test Tablo",
+ tablo_color: "bg-blue-500",
+ tablo_status: "todo",
+ title: "Event 1",
+ description: "Description 1",
+ start_date: "2024-01-16",
+ start_time: "10:00:00",
+ end_time: "11:00:00",
+ // created_by: mockUser.id,
+ // created_at: "2024-01-01T00:00:00Z",
+ // deleted_at: null,
+ },
+ {
+ event_id: "event2",
+ tablo_id: "tablo1",
+ tablo_name: "Test Tablo",
+ tablo_color: "bg-blue-500",
+ tablo_status: "todo",
+ title: "Event 2",
+ description: "Description 2",
+ start_date: "2024-01-17",
+ start_time: "14:00:00",
+ end_time: "15:00:00",
+ // created_by: mockUser.id,
+ // created_at: "2024-01-01T00:00:00Z",
+ // deleted_at: null,
+ },
+ ];
+
+ const icsContent = generateICSFromEvents(events, "Test Calendar");
+
+ const eventCount = (icsContent.match(/BEGIN:VEVENT/g) || []).length;
+ expect(eventCount).to.equal(2);
+ expect(icsContent).to.include("SUMMARY:Event 1");
+ expect(icsContent).to.include("SUMMARY:Event 2");
+ });
+ });
+
+ describe("writeCalendarFileToR2", () => {
+ it("should write calendar file to R2 successfully", async () => {
+ const events: EventAndTablo[] = [
+ {
+ event_id: "event1",
+ tablo_id: mockTablo.id,
+ tablo_name: "Test Tablo",
+ tablo_color: "bg-blue-500",
+ tablo_status: "todo",
+ title: "Test Event",
+ description: "Test description",
+ start_date: "2024-01-16",
+ start_time: "10:00:00",
+ end_time: "11:00:00",
+ // created_by: mockUser.id,
+ // created_at: "2024-01-01T00:00:00Z",
+ // deleted_at: null,
+ },
+ ];
+
+ const eventsBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().resolves({ data: events, error: null }),
+ };
+
+ mockSupabase.from.withArgs("events_and_tablos").returns(eventsBuilder);
+
+ mockS3.send.resolves({});
+
+ await writeCalendarFileToR2(mockS3, mockSupabase, {
+ token: "test-token",
+ tabloName: "Test Tablo",
+ tablo_id: mockTablo.id,
+ });
+
+ expect(mockS3.send.calledOnce).to.be.true;
+ });
+
+ it("should throw error if events fetch fails", async () => {
+ const eventsBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon
+ .stub()
+ .resolves({ data: null, error: { message: "Database error" } }),
+ };
+
+ mockSupabase.from.withArgs("events_and_tablos").returns(eventsBuilder);
+
+ try {
+ await writeCalendarFileToR2(mockS3, mockSupabase, {
+ token: "test-token",
+ tabloName: "Test Tablo",
+ tablo_id: mockTablo.id,
+ });
+ expect.fail("Should have thrown an error");
+ // biome-ignore lint/suspicious/noExplicitAny: Catching error to check message
+ } catch (error: any) {
+ expect(error.message).to.equal("Failed to generate events");
+ }
+ });
+ });
+
+ describe("isTabloMember", () => {
+ it("should return true if user is a member", async () => {
+ const accessBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ };
+ // The last eq() call should resolve with data
+ accessBuilder.eq.onCall(2).resolves({
+ data: [{ tablo_id: mockTablo.id, user_id: mockUser.id }],
+ error: null,
+ });
+
+ mockSupabase.from.withArgs("tablo_access").returns(accessBuilder);
+
+ const isMember = await isTabloMember(
+ mockSupabase,
+ mockTablo.id,
+ mockUser.id
+ );
+
+ expect(isMember).to.be.true;
+ });
+
+ it("should return false if user is not a member", async () => {
+ const accessBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ };
+ // The last eq() call should resolve with empty data
+ accessBuilder.eq.onCall(2).resolves({ data: [], error: null });
+
+ mockSupabase.from.withArgs("tablo_access").returns(accessBuilder);
+
+ const isMember = await isTabloMember(
+ mockSupabase,
+ mockTablo.id,
+ mockUser.id
+ );
+
+ expect(isMember).to.be.false;
+ });
+
+ it("should return false if database error occurs", async () => {
+ const accessBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ };
+ // The last eq() call should resolve with error
+ accessBuilder.eq
+ .onCall(2)
+ .resolves({ data: null, error: { message: "Database error" } });
+
+ mockSupabase.from.withArgs("tablo_access").returns(accessBuilder);
+
+ const isMember = await isTabloMember(
+ mockSupabase,
+ mockTablo.id,
+ mockUser.id
+ );
+
+ expect(isMember).to.be.false;
+ });
+ });
+
+ describe("isTabloAdmin", () => {
+ it("should return true if user is an admin", async () => {
+ const accessBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ };
+ // The last eq() call (4th call - onCall(3)) should resolve with data
+ accessBuilder.eq.onCall(3).resolves({
+ data: [
+ { tablo_id: mockTablo.id, user_id: mockUser.id, is_admin: true },
+ ],
+ error: null,
+ });
+
+ mockSupabase.from.withArgs("tablo_access").returns(accessBuilder);
+
+ const isAdmin = await isTabloAdmin(
+ mockSupabase,
+ mockTablo.id,
+ mockUser.id
+ );
+
+ expect(isAdmin).to.be.true;
+ });
+
+ it("should return false if user is not an admin", async () => {
+ const accessBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ };
+ // The last eq() call should resolve with empty data
+ accessBuilder.eq.onCall(3).resolves({ data: [], error: null });
+
+ mockSupabase.from.withArgs("tablo_access").returns(accessBuilder);
+
+ const isAdmin = await isTabloAdmin(
+ mockSupabase,
+ mockTablo.id,
+ mockUser.id
+ );
+
+ expect(isAdmin).to.be.false;
+ });
+
+ it("should return false if database error occurs", async () => {
+ const accessBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ };
+ // The last eq() call should resolve with error
+ accessBuilder.eq
+ .onCall(3)
+ .resolves({ data: null, error: { message: "Database error" } });
+
+ mockSupabase.from.withArgs("tablo_access").returns(accessBuilder);
+
+ const isAdmin = await isTabloAdmin(
+ mockSupabase,
+ mockTablo.id,
+ mockUser.id
+ );
+
+ expect(isAdmin).to.be.false;
+ });
+ });
+
+ describe("getTabloFileNames", () => {
+ it("should return list of file names", async () => {
+ mockS3.send.resolves({
+ Contents: [
+ { Key: `${mockTablo.id}/file1.txt` },
+ { Key: `${mockTablo.id}/file2.pdf` },
+ { Key: `${mockTablo.id}/file3.jpg` },
+ ],
+ });
+
+ const fileNames = await getTabloFileNames(mockS3, mockTablo.id);
+
+ expect(fileNames).to.deep.equal(["file1.txt", "file2.pdf", "file3.jpg"]);
+ });
+
+ it("should return empty array if no files exist", async () => {
+ mockS3.send.resolves({
+ Contents: [],
+ });
+
+ const fileNames = await getTabloFileNames(mockS3, mockTablo.id);
+
+ expect(fileNames).to.deep.equal([]);
+ });
+
+ it("should filter out invalid file names", async () => {
+ mockS3.send.resolves({
+ Contents: [
+ { Key: `${mockTablo.id}/file1.txt` },
+ { Key: `${mockTablo.id}/` }, // Empty file name
+ { Key: `${mockTablo.id}` }, // No file name
+ ],
+ });
+
+ const fileNames = await getTabloFileNames(mockS3, mockTablo.id);
+
+ expect(fileNames).to.deep.equal(["file1.txt"]);
+ });
+ });
+});
diff --git a/api/src/__tests__/middleware.test.ts b/api/src/__tests__/middleware.test.ts
new file mode 100644
index 0000000..ba4bb43
--- /dev/null
+++ b/api/src/__tests__/middleware.test.ts
@@ -0,0 +1,179 @@
+import { expect } from "chai";
+import { afterEach, beforeEach, describe, it } from "mocha";
+import sinon from "sinon";
+import {
+ authMiddleware,
+ r2Middleware,
+ streamChatMiddleware,
+ supabaseMiddleware,
+} from "../middleware.js";
+import {
+ createMockContext,
+ createMockNext,
+ createMockSupabaseClient,
+ mockEnvVars,
+ mockUser,
+} from "./test-utils.js";
+
+describe("Middleware", () => {
+ let restoreEnv: () => void;
+
+ beforeEach(() => {
+ restoreEnv = mockEnvVars();
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ restoreEnv();
+ });
+
+ describe("authMiddleware", () => {
+ it("should authenticate valid Bearer token", async () => {
+ const mockSupabase = createMockSupabaseClient();
+ const mockContext = createMockContext();
+ const mockNext = createMockNext();
+
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.req.header.withArgs("Authorization").returns("Bearer valid-token");
+
+ // Mock successful auth
+ mockSupabase.auth.getUser.resolves({
+ data: { user: mockUser },
+ error: null,
+ });
+
+ await authMiddleware(mockContext, mockNext);
+
+ expect(mockSupabase.auth.getUser.calledWith("valid-token")).to.be.true;
+ expect(mockContext.set.calledWith("user", mockUser)).to.be.true;
+ expect(mockNext.calledOnce).to.be.true;
+ });
+
+ it("should return 401 for missing Authorization header", async () => {
+ const mockSupabase = createMockSupabaseClient();
+ const mockContext = createMockContext();
+ const mockNext = createMockNext();
+
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.req.header.withArgs("Authorization").returns(undefined);
+ mockContext.json.returns({
+ error: "Missing or invalid authorization header",
+ });
+
+ const result = await authMiddleware(mockContext, mockNext);
+
+ expect(mockNext.called).to.be.false;
+ expect(result).to.deep.equal({
+ error: "Missing or invalid authorization header",
+ });
+ });
+
+ it("should return 401 for invalid Bearer token format", async () => {
+ const mockSupabase = createMockSupabaseClient();
+ const mockContext = createMockContext();
+ const mockNext = createMockNext();
+
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.req.header.withArgs("Authorization").returns("InvalidFormat");
+ mockContext.json.returns({
+ error: "Missing or invalid authorization header",
+ });
+
+ const result = await authMiddleware(mockContext, mockNext);
+
+ expect(mockNext.called).to.be.false;
+ expect(result).to.deep.equal({
+ error: "Missing or invalid authorization header",
+ });
+ });
+
+ it("should return 401 for invalid or expired token", async () => {
+ const mockSupabase = createMockSupabaseClient();
+ const mockContext = createMockContext();
+ const mockNext = createMockNext();
+
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.req.header.withArgs("Authorization").returns("Bearer invalid-token");
+
+ // Mock auth failure
+ mockSupabase.auth.getUser.resolves({
+ data: { user: null },
+ error: { message: "Invalid token" },
+ });
+
+ mockContext.json.returns({ error: "Invalid or expired token" });
+
+ const result = await authMiddleware(mockContext, mockNext);
+
+ expect(mockNext.called).to.be.false;
+ expect(result).to.deep.equal({ error: "Invalid or expired token" });
+ });
+
+ it("should return 401 when user is null", async () => {
+ const mockSupabase = createMockSupabaseClient();
+ const mockContext = createMockContext();
+ const mockNext = createMockNext();
+
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.req.header.withArgs("Authorization").returns("Bearer valid-token");
+
+ // Mock auth with null user
+ mockSupabase.auth.getUser.resolves({
+ data: { user: null },
+ error: null,
+ });
+
+ mockContext.json.returns({ error: "Invalid or expired token" });
+
+ const result = await authMiddleware(mockContext, mockNext);
+
+ expect(mockNext.called).to.be.false;
+ expect(result).to.deep.equal({ error: "Invalid or expired token" });
+ });
+ });
+
+ describe("supabaseMiddleware", () => {
+ it("should create and set Supabase client in context", async () => {
+ const mockContext = createMockContext();
+ const mockNext = createMockNext();
+
+ await supabaseMiddleware(mockContext, mockNext);
+
+ expect(mockContext.set.calledOnce).to.be.true;
+ const setCall = mockContext.set.getCall(0);
+ expect(setCall.args[0]).to.equal("supabase");
+ expect(setCall.args[1]).to.be.an("object");
+ expect(mockNext.calledOnce).to.be.true;
+ });
+ });
+
+ describe("streamChatMiddleware", () => {
+ it("should create and set Stream Chat client in context", async () => {
+ const mockContext = createMockContext();
+ const mockNext = createMockNext();
+
+ await streamChatMiddleware(mockContext, mockNext);
+
+ expect(mockContext.set.calledOnce).to.be.true;
+ const setCall = mockContext.set.getCall(0);
+ expect(setCall.args[0]).to.equal("streamServerClient");
+ expect(setCall.args[1]).to.be.an("object");
+ expect(mockNext.calledOnce).to.be.true;
+ });
+ });
+
+ describe("r2Middleware", () => {
+ it("should create and set S3 client in context", async () => {
+ const mockContext = createMockContext();
+ const mockNext = createMockNext();
+
+ await r2Middleware(mockContext, mockNext);
+
+ expect(mockContext.set.calledOnce).to.be.true;
+ const setCall = mockContext.set.getCall(0);
+ expect(setCall.args[0]).to.equal("s3_client");
+ expect(setCall.args[1]).to.be.an("object");
+ expect(mockNext.calledOnce).to.be.true;
+ });
+ });
+});
diff --git a/api/src/__tests__/public.test.ts b/api/src/__tests__/public.test.ts
new file mode 100644
index 0000000..0264e11
--- /dev/null
+++ b/api/src/__tests__/public.test.ts
@@ -0,0 +1,509 @@
+import { expect } from "chai";
+import { afterEach, beforeEach, describe, it } from "mocha";
+import sinon from "sinon";
+import {
+ createMockContext,
+ createMockSupabaseClient,
+ mockEnvVars,
+ mockEvent,
+ mockProfile,
+} from "./test-utils.js";
+
+describe("Public Router", () => {
+ // biome-ignore lint/suspicious/noExplicitAny: Mock client types
+ let mockSupabase: any;
+ let restoreEnv: () => void;
+
+ beforeEach(() => {
+ restoreEnv = mockEnvVars();
+ mockSupabase = createMockSupabaseClient();
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ restoreEnv();
+ });
+
+ describe("GET /slots/:shortUserId/:standardName", () => {
+ it("should return available slots for valid user and event type", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("shortUserId").returns("testuser");
+ mockContext.req.param.withArgs("standardName").returns("meeting-30min");
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ const eventType = {
+ id: "event-type-id",
+ user_id: mockProfile.id,
+ standard_name: "meeting-30min",
+ config: {
+ name: "30 Minute Meeting",
+ description: "Standard meeting",
+ duration: 30,
+ requiresApproval: false,
+ },
+ created_at: "2024-01-01T00:00:00Z",
+ deleted_at: null,
+ };
+
+ const availability = {
+ id: "availability-id",
+ user_id: mockProfile.id,
+ availability_data: {
+ 0: { enabled: true, timeRanges: [{ start: "09:00", end: "17:00" }] },
+ 1: { enabled: true, timeRanges: [{ start: "09:00", end: "17:00" }] },
+ 2: { enabled: true, timeRanges: [{ start: "09:00", end: "17:00" }] },
+ 3: { enabled: true, timeRanges: [{ start: "09:00", end: "17:00" }] },
+ 4: { enabled: true, timeRanges: [{ start: "09:00", end: "17:00" }] },
+ 5: { enabled: false, timeRanges: [] },
+ 6: { enabled: false, timeRanges: [] },
+ },
+ exceptions: [],
+ };
+
+ // Mock user lookup
+ const userBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: mockProfile, error: null }),
+ };
+
+ // Mock event type lookup
+ const eventTypeBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ is: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: eventType, error: null }),
+ };
+
+ // Mock availabilities lookup
+ const availabilityBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: availability, error: null }),
+ };
+
+ // Mock events lookup
+ const eventsBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ gte: sinon.stub().returnsThis(),
+ lte: sinon.stub().returnsThis(),
+ is: sinon.stub().resolves({ data: [], error: null }),
+ };
+
+ mockSupabase.from.callsFake((table: string) => {
+ if (table === "profiles") return userBuilder;
+ if (table === "event_types") return eventTypeBuilder;
+ if (table === "availabilities") return availabilityBuilder;
+ if (table === "events") return eventsBuilder;
+ return mockSupabase.from();
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const supabase = c.get("supabase");
+ const shortUserId = c.req.param("shortUserId");
+ const standardName = c.req.param("standardName");
+
+ // Get user
+ const { data: userData, error: userError } = await supabase
+ .from("profiles")
+ .select("*")
+ .eq("short_user_id", shortUserId)
+ .single();
+
+ if (userError || !userData) {
+ return c.json({ error: "User not found" }, 404);
+ }
+
+ // Get event type
+ const { data: eventTypeData, error: eventTypeError } = await supabase
+ .from("event_types")
+ .select("*")
+ .eq("user_id", userData.id)
+ .eq("standard_name", standardName)
+ .is("deleted_at", null)
+ .single();
+
+ if (eventTypeError || !eventTypeData) {
+ return c.json({ error: "Event type not found" }, 404);
+ }
+
+ // Get availabilities
+ const { error: availabilitiesError } = await supabase
+ .from("availabilities")
+ .select("*")
+ .eq("user_id", userData.id)
+ .single();
+
+ if (availabilitiesError) {
+ return c.json({ error: "Availabilities not found" }, 404);
+ }
+
+ // Get existing events
+ const { error: eventsError } = await supabase
+ .from("events")
+ .select("*")
+ .eq("created_by", userData.id)
+ .gte("start_date", "2024-01-01")
+ .lte("start_date", "2024-12-31")
+ .is("deleted_at", null);
+
+ if (eventsError) {
+ return c.json({ error: "Failed to fetch events" }, 500);
+ }
+
+ return c.json({
+ user: { name: userData.name },
+ eventType: eventTypeData.config,
+ slots: {},
+ availableSlots: [],
+ });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result.user.name).to.equal(mockProfile.name);
+ expect(result.eventType.name).to.equal("30 Minute Meeting");
+ });
+
+ it("should return 404 if user not found", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("shortUserId").returns("nonexistent");
+ mockContext.req.param.withArgs("standardName").returns("meeting-30min");
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ // Mock user lookup with no data
+ const userBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon
+ .stub()
+ .resolves({ data: null, error: { message: "Not found" } }),
+ };
+
+ mockSupabase.from.withArgs("profiles").returns(userBuilder);
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const supabase = c.get("supabase");
+ const shortUserId = c.req.param("shortUserId");
+
+ const { data: userData, error: userError } = await supabase
+ .from("profiles")
+ .select("*")
+ .eq("short_user_id", shortUserId)
+ .single();
+
+ if (userError || !userData) {
+ return c.json({ error: "User not found" }, 404);
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "User not found" });
+ });
+
+ it("should return 404 if event type not found", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("shortUserId").returns("testuser");
+ mockContext.req.param.withArgs("standardName").returns("nonexistent");
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ // Mock user lookup
+ const userBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: mockProfile, error: null }),
+ };
+
+ // Mock event type lookup with no data
+ const eventTypeBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ is: sinon.stub().returnsThis(),
+ single: sinon
+ .stub()
+ .resolves({ data: null, error: { message: "Not found" } }),
+ };
+
+ mockSupabase.from.callsFake((table: string) => {
+ if (table === "profiles") return userBuilder;
+ if (table === "event_types") return eventTypeBuilder;
+ return mockSupabase.from();
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const supabase = c.get("supabase");
+ const shortUserId = c.req.param("shortUserId");
+ const standardName = c.req.param("standardName");
+
+ // Get user
+ const { data: userData, error: userError } = await supabase
+ .from("profiles")
+ .select("*")
+ .eq("short_user_id", shortUserId)
+ .single();
+
+ if (userError || !userData) {
+ return c.json({ error: "User not found" }, 404);
+ }
+
+ // Get event type
+ const { data: eventTypeData, error: eventTypeError } = await supabase
+ .from("event_types")
+ .select("*")
+ .eq("user_id", userData.id)
+ .eq("standard_name", standardName)
+ .is("deleted_at", null)
+ .single();
+
+ if (eventTypeError || !eventTypeData) {
+ return c.json({ error: "Event type not found" }, 404);
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Event type not found" });
+ });
+
+ it("should return 404 if availabilities not found", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("shortUserId").returns("testuser");
+ mockContext.req.param.withArgs("standardName").returns("meeting-30min");
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ const eventType = {
+ id: "event-type-id",
+ user_id: mockProfile.id,
+ standard_name: "meeting-30min",
+ config: {
+ name: "30 Minute Meeting",
+ description: "Standard meeting",
+ duration: 30,
+ requiresApproval: false,
+ },
+ created_at: "2024-01-01T00:00:00Z",
+ deleted_at: null,
+ };
+
+ // Mock user lookup
+ const userBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: mockProfile, error: null }),
+ };
+
+ // Mock event type lookup
+ const eventTypeBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ is: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: eventType, error: null }),
+ };
+
+ // Mock availabilities lookup with error
+ const availabilityBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon
+ .stub()
+ .resolves({ data: null, error: { message: "Not found" } }),
+ };
+
+ mockSupabase.from.callsFake((table: string) => {
+ if (table === "profiles") return userBuilder;
+ if (table === "event_types") return eventTypeBuilder;
+ if (table === "availabilities") return availabilityBuilder;
+ return mockSupabase.from();
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const supabase = c.get("supabase");
+ const shortUserId = c.req.param("shortUserId");
+ const standardName = c.req.param("standardName");
+
+ // Get user
+ const { data: userData, error: userError } = await supabase
+ .from("profiles")
+ .select("*")
+ .eq("short_user_id", shortUserId)
+ .single();
+
+ if (userError || !userData) {
+ return c.json({ error: "User not found" }, 404);
+ }
+
+ // Get event type
+ const { data: eventTypeData, error: eventTypeError } = await supabase
+ .from("event_types")
+ .select("*")
+ .eq("user_id", userData.id)
+ .eq("standard_name", standardName)
+ .is("deleted_at", null)
+ .single();
+
+ if (eventTypeError || !eventTypeData) {
+ return c.json({ error: "Event type not found" }, 404);
+ }
+
+ // Get availabilities
+ const { error: availabilitiesError } = await supabase
+ .from("availabilities")
+ .select("*")
+ .eq("user_id", userData.id)
+ .single();
+
+ if (availabilitiesError) {
+ return c.json({ error: "Availabilities not found" }, 404);
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Availabilities not found" });
+ });
+
+ it("should return 500 if events query fails", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("shortUserId").returns("testuser");
+ mockContext.req.param.withArgs("standardName").returns("meeting-30min");
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ const eventType = {
+ id: "event-type-id",
+ user_id: mockProfile.id,
+ standard_name: "meeting-30min",
+ config: {
+ name: "30 Minute Meeting",
+ description: "Standard meeting",
+ duration: 30,
+ requiresApproval: false,
+ },
+ created_at: "2024-01-01T00:00:00Z",
+ deleted_at: null,
+ };
+
+ const availability = {
+ id: "availability-id",
+ user_id: mockProfile.id,
+ availability_data: {
+ 0: { enabled: true, timeRanges: [{ start: "09:00", end: "17:00" }] },
+ },
+ exceptions: [],
+ };
+
+ // Mock user lookup
+ const userBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: mockProfile, error: null }),
+ };
+
+ // Mock event type lookup
+ const eventTypeBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ is: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: eventType, error: null }),
+ };
+
+ // Mock availabilities lookup
+ const availabilityBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: availability, error: null }),
+ };
+
+ // Mock events lookup with error
+ const eventsBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ gte: sinon.stub().returnsThis(),
+ lte: sinon.stub().returnsThis(),
+ is: sinon
+ .stub()
+ .resolves({ data: null, error: { message: "Database error" } }),
+ };
+
+ mockSupabase.from.callsFake((table: string) => {
+ if (table === "profiles") return userBuilder;
+ if (table === "event_types") return eventTypeBuilder;
+ if (table === "availabilities") return availabilityBuilder;
+ if (table === "events") return eventsBuilder;
+ return mockSupabase.from();
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const supabase = c.get("supabase");
+ const shortUserId = c.req.param("shortUserId");
+ const standardName = c.req.param("standardName");
+
+ // Get user
+ const { data: userData, error: userError } = await supabase
+ .from("profiles")
+ .select("*")
+ .eq("short_user_id", shortUserId)
+ .single();
+
+ if (userError || !userData) {
+ return c.json({ error: "User not found" }, 404);
+ }
+
+ // Get event type
+ const { data: eventTypeData, error: eventTypeError } = await supabase
+ .from("event_types")
+ .select("*")
+ .eq("user_id", userData.id)
+ .eq("standard_name", standardName)
+ .is("deleted_at", null)
+ .single();
+
+ if (eventTypeError || !eventTypeData) {
+ return c.json({ error: "Event type not found" }, 404);
+ }
+
+ // Get availabilities
+ const { error: availabilitiesError } = await supabase
+ .from("availabilities")
+ .select("*")
+ .eq("user_id", userData.id)
+ .single();
+
+ if (availabilitiesError) {
+ return c.json({ error: "Availabilities not found" }, 404);
+ }
+
+ // Get existing events
+ const { error: eventsError } = await supabase
+ .from("events")
+ .select("*")
+ .eq("created_by", userData.id)
+ .gte("start_date", "2024-01-01")
+ .lte("start_date", "2024-12-31")
+ .is("deleted_at", null);
+
+ if (eventsError) {
+ return c.json({ error: "Failed to fetch events" }, 500);
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Failed to fetch events" });
+ });
+ });
+});
diff --git a/api/src/__tests__/slots.test.ts b/api/src/__tests__/slots.test.ts
index b23e3c6..136ccd9 100644
--- a/api/src/__tests__/slots.test.ts
+++ b/api/src/__tests__/slots.test.ts
@@ -1,14 +1,14 @@
-import { describe, it, beforeEach } from "mocha";
import { expect } from "chai";
-import {
- generateTimeSlots,
- getDayOfWeek,
- getDateString,
- type WeeklyAvailability,
- type Exception,
- type EventTypeConfig,
-} from "../slots.js";
+import { beforeEach, describe, it } from "mocha";
import type { Tables } from "../database.types.js";
+import {
+ type EventTypeConfig,
+ type Exception,
+ generateTimeSlots,
+ getDateString,
+ getDayOfWeek,
+ type WeeklyAvailability,
+} from "../slots.js";
// Mock the current date for consistent testing
diff --git a/api/src/__tests__/tablo.test.ts b/api/src/__tests__/tablo.test.ts
new file mode 100644
index 0000000..71e94c3
--- /dev/null
+++ b/api/src/__tests__/tablo.test.ts
@@ -0,0 +1,1287 @@
+import { expect } from "chai";
+import { afterEach, beforeEach, describe, it } from "mocha";
+import sinon from "sinon";
+import {
+ createMockContext,
+ createMockS3Client,
+ createMockStreamChatClient,
+ createMockSupabaseClient,
+ createMockTransporter,
+ mockEnvVars,
+ mockEvent,
+ mockProfile,
+ mockTablo,
+ mockUser,
+} from "./test-utils.js";
+
+describe("Tablo Router", () => {
+ // biome-ignore lint/suspicious/noExplicitAny: Mock client types
+ let mockSupabase: any;
+ // biome-ignore lint/suspicious/noExplicitAny: Mock client types
+ let mockStreamChat: any;
+ // biome-ignore lint/suspicious/noExplicitAny: Mock client types
+ let mockChannel: any;
+ // biome-ignore lint/suspicious/noExplicitAny: Mock client types
+ let mockS3: any;
+ let restoreEnv: () => void;
+
+ beforeEach(() => {
+ restoreEnv = mockEnvVars();
+ mockSupabase = createMockSupabaseClient();
+ const streamMocks = createMockStreamChatClient();
+ mockStreamChat = streamMocks.mockStreamChat;
+ mockChannel = streamMocks.mockChannel;
+ mockS3 = createMockS3Client();
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ restoreEnv();
+ });
+
+ describe("POST /create", () => {
+ it("should create a new tablo with events", async () => {
+ const mockContext = createMockContext();
+ const payload = {
+ name: "New Tablo",
+ color: "bg-blue-500",
+ status: "todo",
+ events: [
+ {
+ title: "Event 1",
+ description: "Test event",
+ start_date: "2024-01-16",
+ start_time: "10:00",
+ end_time: "11:00",
+ },
+ ],
+ };
+
+ mockContext.req.json.resolves(payload);
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("streamServerClient").returns(mockStreamChat);
+
+ // Mock Supabase insert
+ mockSupabase
+ .from()
+ .insert()
+ .select()
+ .single.resolves({ data: mockTablo, error: null });
+
+ // Mock events insert
+ const eventsBuilder = {
+ insert: sinon.stub().resolves({ data: [], error: null }),
+ };
+ mockSupabase.from.withArgs("events").returns(eventsBuilder);
+
+ // Create test handler
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const user = c.get("user");
+ const supabase = c.get("supabase");
+ const data = await c.req.json();
+
+ const { data: insertedTablo, error } = await supabase
+ .from("tablos")
+ .insert({
+ ...data,
+ owner_id: user.id,
+ events: undefined,
+ })
+ .select()
+ .single();
+
+ if (error || !insertedTablo) {
+ return c.json(
+ { error: error?.message || "Failed to create tablo" },
+ 500
+ );
+ }
+
+ const streamServerClient = c.get("streamServerClient");
+ const channel = streamServerClient.channel(
+ "messaging",
+ insertedTablo.id,
+ {
+ name: insertedTablo.name,
+ created_by_id: user.id,
+ members: [user.id],
+ }
+ );
+ await channel.create();
+
+ if (data.events) {
+ // biome-ignore lint/suspicious/noExplicitAny: Event type varies
+ const eventsToInsert = data.events.map((event: any) => ({
+ ...event,
+ tablo_id: insertedTablo.id,
+ created_by: user.id,
+ }));
+
+ await supabase.from("events").insert(eventsToInsert);
+ }
+
+ return c.json({ message: "Tablo created successfully" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(mockStreamChat.channel.calledOnce).to.be.true;
+ expect(mockChannel.create.calledOnce).to.be.true;
+ expect(result).to.deep.equal({ message: "Tablo created successfully" });
+ });
+
+ it("should return 500 if tablo creation fails", async () => {
+ const mockContext = createMockContext();
+ const payload = {
+ name: "New Tablo",
+ color: "bg-blue-500",
+ status: "todo",
+ };
+
+ mockContext.req.json.resolves(payload);
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ // Mock Supabase error
+ mockSupabase
+ .from()
+ .insert()
+ .select()
+ .single.resolves({ data: null, error: { message: "Insert failed" } });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const user = c.get("user");
+ const supabase = c.get("supabase");
+ const data = await c.req.json();
+
+ const { error } = await supabase
+ .from("tablos")
+ .insert({
+ ...data,
+ owner_id: user.id,
+ events: undefined,
+ })
+ .select()
+ .single();
+
+ if (error) {
+ return c.json({ error: error.message }, 500);
+ }
+
+ return c.json({ message: "Tablo created successfully" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Insert failed" });
+ });
+ });
+
+ describe("POST /create-and-invite", () => {
+ it("should create tablo and grant access to invited user", async () => {
+ const mockContext = createMockContext();
+ const ownerProfile = {
+ ...mockProfile,
+ id: "owner-id",
+ short_user_id: "owner123",
+ };
+ const invitedProfile = { ...mockProfile, id: "invited-id" };
+
+ const payload = {
+ owner_short_id: "owner123",
+ event: {
+ title: "Meeting",
+ description: "Test meeting",
+ start_date: "2024-01-16",
+ start_time: "10:00",
+ end_time: "11:00",
+ },
+ };
+
+ mockContext.req.json.resolves(payload);
+ mockContext.get
+ .withArgs("user")
+ .returns({ ...mockUser, id: "invited-id" });
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("streamServerClient").returns(mockStreamChat);
+
+ // Mock owner lookup
+ const ownerBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: ownerProfile, error: null }),
+ };
+
+ // Mock invited user lookup
+ const invitedBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: invitedProfile, error: null }),
+ };
+
+ // Mock existing tablo check
+ const existingTabloBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ is: sinon.stub().returnsThis(),
+ limit: sinon.stub().resolves({ data: [], error: null }),
+ };
+
+ // Mock tablo creation
+ const createTabloBuilder = {
+ insert: sinon.stub().returnsThis(),
+ select: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: mockTablo, error: null }),
+ };
+
+ // Mock tablo access insert
+ const accessBuilder = {
+ insert: sinon.stub().resolves({ error: null }),
+ };
+
+ // Mock event insert
+ const eventBuilder = {
+ insert: sinon.stub().resolves({ error: null }),
+ };
+
+ let callCount = 0;
+ mockSupabase.from.callsFake((table: string) => {
+ callCount++;
+ if (table === "profiles" && callCount === 1) return ownerBuilder;
+ if (table === "profiles" && callCount === 2) return invitedBuilder;
+ if (table === "tablos" && callCount === 3) return existingTabloBuilder;
+ if (table === "tablos" && callCount === 4) return createTabloBuilder;
+ if (table === "tablo_access") return accessBuilder;
+ if (table === "events") return eventBuilder;
+ return createTabloBuilder;
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const user = c.get("user");
+ const supabase = c.get("supabase");
+ const streamServerClient = c.get("streamServerClient");
+ const data = await c.req.json();
+
+ if (!data.owner_short_id) {
+ return c.json({ error: "owner_id is required" }, 400);
+ }
+
+ if (!data.event) {
+ return c.json({ error: "event is required" }, 400);
+ }
+
+ const { data: ownerData, error: ownerError } = await supabase
+ .from("profiles")
+ .select("id, name, email")
+ .eq("short_user_id", data.owner_short_id)
+ .single();
+
+ const { data: invitedUser, error: invitedUserError } = await supabase
+ .from("profiles")
+ .select("id, name, email")
+ .eq("id", user.id)
+ .single();
+
+ if (ownerError || !ownerData || invitedUserError || !invitedUser) {
+ return c.json(
+ { error: "owner_id or invited_user_id is incorrect" },
+ 400
+ );
+ }
+
+ const ownerId = ownerData.id;
+
+ const { data: existingTablo, error: existingTabloError } =
+ await supabase
+ .from("tablos")
+ .select(
+ `
+ id,
+ name,
+ owner_id,
+ tablo_access!inner(user_id)
+ `
+ )
+ .eq("owner_id", ownerId)
+ .eq("tablo_access.user_id", user.id)
+ .is("deleted_at", null)
+ .limit(1);
+
+ if (existingTabloError) {
+ return c.json({ error: existingTabloError.message }, 500);
+ }
+
+ let tabloData: typeof mockTablo;
+
+ if (!existingTablo.length) {
+ const { data: insertedTablo, error } = await supabase
+ .from("tablos")
+ .insert({
+ name: `${invitedUser.name || "Invité"} / ${
+ ownerData.name || "Propriétaire"
+ }`,
+ color: "bg-blue-500",
+ status: "todo",
+ owner_id: ownerId,
+ })
+ .select()
+ .single();
+
+ if (error || !insertedTablo) {
+ return c.json(
+ { error: error?.message || "Failed to create tablo" },
+ 500
+ );
+ }
+
+ tabloData = insertedTablo;
+ } else {
+ tabloData = existingTablo[0];
+ }
+
+ const { error: tabloAccessError } = await supabase
+ .from("tablo_access")
+ .insert({
+ tablo_id: tabloData.id,
+ user_id: user.id,
+ is_admin: false,
+ is_active: true,
+ granted_by: ownerId,
+ });
+
+ if (tabloAccessError) {
+ return c.json({ error: tabloAccessError.message }, 500);
+ }
+
+ const channel = streamServerClient.channel("messaging", tabloData.id, {
+ name: tabloData.name,
+ created_by_id: ownerId,
+ members: [ownerId, user.id],
+ });
+ await channel.create();
+
+ await channel.sendMessage({
+ text: `🎉 Bienvenue dans votre nouveau tablo "${tabloData.name}" !`,
+ user_id: ownerId,
+ });
+
+ await supabase.from("events").insert({
+ ...data.event,
+ tablo_id: tabloData.id,
+ created_by: ownerId,
+ });
+
+ return c.json({ id: tabloData.id });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ id: mockTablo.id });
+ expect(mockChannel.create.calledOnce).to.be.true;
+ expect(mockChannel.sendMessage.calledOnce).to.be.true;
+ });
+
+ it("should return 400 if owner_short_id is missing", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.json.resolves({ event: {} });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const data = await c.req.json();
+
+ if (!data.owner_short_id) {
+ return c.json({ error: "owner_id is required" }, 400);
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "owner_id is required" });
+ });
+
+ it("should return 400 if event is missing", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.json.resolves({ owner_short_id: "owner123" });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const data = await c.req.json();
+
+ if (!data.owner_short_id) {
+ return c.json({ error: "owner_id is required" }, 400);
+ }
+
+ if (!data.event) {
+ return c.json({ error: "event is required" }, 400);
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "event is required" });
+ });
+ });
+
+ describe("PATCH /update", () => {
+ it("should update tablo successfully", async () => {
+ const mockContext = createMockContext();
+ const updateData = {
+ id: mockTablo.id,
+ name: "Updated Tablo Name",
+ };
+
+ mockContext.req.json.resolves(updateData);
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("streamServerClient").returns(mockStreamChat);
+
+ const updatedTablo = { ...mockTablo, name: "Updated Tablo Name" };
+
+ mockSupabase
+ .from()
+ .update()
+ .eq()
+ .select()
+ .single.resolves({ data: updatedTablo, error: null });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const user = c.get("user");
+ const supabase = c.get("supabase");
+ const streamServerClient = c.get("streamServerClient");
+ const data = await c.req.json();
+
+ const { id, ...tablo } = data;
+
+ const { data: update, error } = await supabase
+ .from("tablos")
+ .update(tablo)
+ .eq("id", id)
+ .eq("owner_id", user.id)
+ .select()
+ .single();
+
+ if (error || !update) {
+ return c.json({ error: error?.message || "Failed to update" }, 500);
+ }
+
+ const isUpdatingName =
+ tablo.name !== undefined && tablo.name !== update.name;
+
+ if (isUpdatingName) {
+ const channel = streamServerClient.channel("messaging", update.id);
+ try {
+ await channel.update({
+ name: update.name,
+ });
+ } catch (error) {
+ console.error("error updating channel", error);
+ }
+ }
+
+ return c.json({ message: "Tablo updated successfully" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ message: "Tablo updated successfully" });
+ });
+
+ it("should return 500 if update fails", async () => {
+ const mockContext = createMockContext();
+ const updateData = {
+ id: mockTablo.id,
+ name: "Updated Name",
+ };
+
+ mockContext.req.json.resolves(updateData);
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ mockSupabase
+ .from()
+ .update()
+ .eq()
+ .select()
+ .single.resolves({ data: null, error: { message: "Update failed" } });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const user = c.get("user");
+ const supabase = c.get("supabase");
+ const data = await c.req.json();
+
+ const { id, ...tablo } = data;
+
+ const { error } = await supabase
+ .from("tablos")
+ .update(tablo)
+ .eq("id", id)
+ .eq("owner_id", user.id)
+ .select()
+ .single();
+
+ if (error) {
+ return c.json({ error: error.message }, 500);
+ }
+
+ return c.json({ message: "Tablo updated successfully" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Update failed" });
+ });
+ });
+
+ describe("DELETE /delete", () => {
+ it("should soft delete tablo successfully", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.json.resolves({ id: mockTablo.id });
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("streamServerClient").returns(mockStreamChat);
+
+ const updateBuilder = {
+ update: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ };
+ updateBuilder.eq.onCall(1).resolves({ error: null });
+
+ mockSupabase.from.withArgs("tablos").returns(updateBuilder);
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const user = c.get("user");
+ const supabase = c.get("supabase");
+ const streamServerClient = c.get("streamServerClient");
+ const data = await c.req.json();
+
+ const { id } = data;
+
+ const { error } = await supabase
+ .from("tablos")
+ .update({ deleted_at: new Date().toISOString() })
+ .eq("id", id)
+ .eq("owner_id", user.id);
+
+ if (error) {
+ return c.json({ error: error.message }, 500);
+ }
+
+ const channel = streamServerClient.channel("messaging", id);
+ try {
+ await channel.delete();
+ } catch (error) {
+ console.error("error deleting channel", error);
+ }
+
+ return c.json({ message: "Tablo deleted successfully" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ message: "Tablo deleted successfully" });
+ expect(mockChannel.delete.calledOnce).to.be.true;
+ });
+
+ it("should return 500 if delete fails", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.json.resolves({ id: mockTablo.id });
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ const updateBuilder = {
+ update: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ };
+ updateBuilder.eq
+ .onCall(1)
+ .resolves({ error: { message: "Delete failed" } });
+
+ mockSupabase.from.withArgs("tablos").returns(updateBuilder);
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const user = c.get("user");
+ const supabase = c.get("supabase");
+ const data = await c.req.json();
+
+ const { id } = data;
+
+ const { error } = await supabase
+ .from("tablos")
+ .update({ deleted_at: new Date().toISOString() })
+ .eq("id", id)
+ .eq("owner_id", user.id);
+
+ if (error) {
+ return c.json({ error: error.message }, 500);
+ }
+
+ return c.json({ message: "Tablo deleted successfully" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Delete failed" });
+ });
+ });
+
+ describe("POST /invite", () => {
+ it("should send invite successfully", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.json.resolves({
+ email: "invitee@example.com",
+ tablo_id: mockTablo.id,
+ });
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ // Mock tablo lookup
+ mockSupabase
+ .from()
+ .select()
+ .eq()
+ .single.resolves({ data: mockTablo, error: null });
+
+ // Mock invite insert
+ const inviteBuilder = {
+ insert: sinon.stub().resolves({ error: null }),
+ };
+ mockSupabase.from.withArgs("tablo_invites").returns(inviteBuilder);
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const sender = c.get("user");
+ const supabase = c.get("supabase");
+ const { tablo_id, email } = await c.req.json();
+
+ const { data, error: tabloError } = await supabase
+ .from("tablos")
+ .select("*")
+ .eq("id", tablo_id)
+ .single();
+
+ if (tabloError) {
+ return c.json({ error: tabloError.message }, 500);
+ }
+
+ if (!data) {
+ return c.json({ error: "Tablo not found" }, 404);
+ }
+
+ if (data.owner_id !== sender.id) {
+ return c.json(
+ { error: "You are not allowed to invite users to this tablo" },
+ 400
+ );
+ }
+
+ const { error } = await supabase.from("tablo_invites").insert({
+ invited_email: email,
+ tablo_id: tablo_id,
+ invited_by: sender.id,
+ invite_token: "mock-token",
+ });
+
+ if (error) {
+ return c.json({ error: error.message }, 500);
+ }
+
+ return c.json({
+ message: "Invite sent successfully",
+ });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ message: "Invite sent successfully" });
+ });
+
+ it("should return 404 if tablo not found", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.json.resolves({
+ email: "invitee@example.com",
+ tablo_id: "non-existent",
+ });
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ mockSupabase
+ .from()
+ .select()
+ .eq()
+ .single.resolves({ data: null, error: null });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const _sender = c.get("user");
+ const supabase = c.get("supabase");
+ const { tablo_id } = await c.req.json();
+
+ const { data, error: tabloError } = await supabase
+ .from("tablos")
+ .select("*")
+ .eq("id", tablo_id)
+ .single();
+
+ if (tabloError) {
+ return c.json({ error: tabloError.message }, 500);
+ }
+
+ if (!data) {
+ return c.json({ error: "Tablo not found" }, 404);
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Tablo not found" });
+ });
+
+ it("should return 400 if user is not owner", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.json.resolves({
+ email: "invitee@example.com",
+ tablo_id: mockTablo.id,
+ });
+ mockContext.get
+ .withArgs("user")
+ .returns({ ...mockUser, id: "different-user" });
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ mockSupabase
+ .from()
+ .select()
+ .eq()
+ .single.resolves({ data: mockTablo, error: null });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const sender = c.get("user");
+ const supabase = c.get("supabase");
+ const { tablo_id } = await c.req.json();
+
+ const { data, error: tabloError } = await supabase
+ .from("tablos")
+ .select("*")
+ .eq("id", tablo_id)
+ .single();
+
+ if (tabloError) {
+ return c.json({ error: tabloError.message }, 500);
+ }
+
+ if (!data) {
+ return c.json({ error: "Tablo not found" }, 404);
+ }
+
+ if (data.owner_id !== sender.id) {
+ return c.json(
+ { error: "You are not allowed to invite users to this tablo" },
+ 400
+ );
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({
+ error: "You are not allowed to invite users to this tablo",
+ });
+ });
+ });
+
+ describe("POST /join", () => {
+ it("should join tablo successfully with valid token", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.json.resolves({ token: "valid-token" });
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("streamServerClient").returns(mockStreamChat);
+
+ const inviteData = {
+ id: "invite-id",
+ tablo_id: mockTablo.id,
+ invited_by: "inviter-id",
+ };
+
+ // Mock invite lookup
+ const inviteSelectBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: inviteData, error: null }),
+ };
+
+ // Mock tablo access insert
+ const accessBuilder = {
+ insert: sinon.stub().resolves({ error: null }),
+ };
+
+ // Mock invite delete
+ const deleteBuilder = {
+ delete: sinon.stub().returnsThis(),
+ eq: sinon.stub().resolves({ error: null }),
+ };
+
+ let callCount = 0;
+ mockSupabase.from.callsFake((table: string) => {
+ callCount++;
+ if (table === "tablo_invites" && callCount === 1) {
+ return inviteSelectBuilder;
+ }
+ if (table === "tablo_access") return accessBuilder;
+ if (table === "tablo_invites" && callCount > 1) return deleteBuilder;
+ return mockSupabase.from();
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const { token } = await c.req.json();
+
+ const joiner = c.get("user");
+ const supabase = c.get("supabase");
+ const streamServerClient = c.get("streamServerClient");
+
+ const { data: inviteData, error } = await supabase
+ .from("tablo_invites")
+ .select("id, tablo_id, invited_by")
+ .eq("invite_token", token)
+ .eq("invited_email", joiner.email)
+ .single();
+
+ if (error) {
+ return c.json({ error: error.message }, 500);
+ }
+
+ if (!inviteData) {
+ return c.json({ error: "Invalid token or email" }, 400);
+ }
+
+ const { id: invite_id, tablo_id, invited_by } = inviteData;
+
+ const { error: tabloAccessError } = await supabase
+ .from("tablo_access")
+ .insert({
+ tablo_id,
+ user_id: joiner.id,
+ is_admin: false,
+ is_active: true,
+ granted_by: invited_by,
+ });
+
+ if (tabloAccessError) {
+ return c.json({ error: tabloAccessError.message }, 500);
+ }
+
+ await supabase.from("tablo_invites").delete().eq("id", invite_id);
+
+ const channel = streamServerClient.channel("messaging", tablo_id);
+ await channel.addMembers([joiner.id]);
+
+ return c.json({ message: "Tablo joined successfully" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ message: "Tablo joined successfully" });
+ expect(mockChannel.addMembers.calledOnce).to.be.true;
+ });
+
+ it("should return 400 for invalid token", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.json.resolves({ token: "invalid-token" });
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ mockSupabase
+ .from()
+ .select()
+ .eq()
+ .single.resolves({ data: null, error: null });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const { token } = await c.req.json();
+ const joiner = c.get("user");
+ const supabase = c.get("supabase");
+
+ const { data: inviteData, error } = await supabase
+ .from("tablo_invites")
+ .select("id, tablo_id, invited_by")
+ .eq("invite_token", token)
+ .eq("invited_email", joiner.email)
+ .single();
+
+ if (error) {
+ return c.json({ error: error.message }, 500);
+ }
+
+ if (!inviteData) {
+ return c.json({ error: "Invalid token or email" }, 400);
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Invalid token or email" });
+ });
+ });
+
+ describe("GET /members/:tablo_id", () => {
+ it("should return tablo members", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("tablo_id").returns(mockTablo.id);
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ const members = [
+ { is_admin: true, profiles: { id: "user1", name: "User 1" } },
+ { is_admin: false, profiles: { id: "user2", name: "User 2" } },
+ ];
+
+ // Mock user_tablos check
+ const userTablosBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ };
+ // The second eq() call should resolve
+ userTablosBuilder.eq
+ .onCall(1)
+ .resolves({ data: [mockTablo], error: null });
+
+ // Mock tablo_access query
+ const accessBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ };
+ // The second eq() call should resolve
+ accessBuilder.eq.onCall(1).resolves({ data: members, error: null });
+
+ mockSupabase.from.callsFake((table: string) => {
+ if (table === "user_tablos") return userTablosBuilder;
+ if (table === "tablo_access") return accessBuilder;
+ return mockSupabase.from();
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const user = c.get("user");
+ const supabase = c.get("supabase");
+ const tablo_id = c.req.param("tablo_id");
+
+ const { data: tabloData, error: tabloError } = await supabase
+ .from("user_tablos")
+ .select("*")
+ .eq("id", tablo_id)
+ .eq("user_id", user.id);
+
+ if (!tabloData || tabloData.length === 0) {
+ return c.json({ error: "You are not a member of this tablo" }, 403);
+ }
+
+ if (tabloError) {
+ return c.json({ error: "Internal server error" }, 500);
+ }
+
+ const { data, error } = await supabase
+ .from("tablo_access")
+ .select("is_admin, profiles(id, name)")
+ .eq("tablo_id", tablo_id)
+ .eq("is_active", true);
+
+ if (error) {
+ return c.json({ error: error.message }, 500);
+ }
+
+ return c.json({
+ // biome-ignore lint/suspicious/noExplicitAny: Member type from DB
+ members: data.map((member: any) => ({
+ ...member.profiles,
+ is_admin: member.is_admin,
+ })),
+ });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result.members).to.have.length(2);
+ expect(result.members[0]).to.deep.equal({
+ id: "user1",
+ name: "User 1",
+ is_admin: true,
+ });
+ });
+
+ it("should return 403 if user is not a member", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("tablo_id").returns(mockTablo.id);
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ const userTablosBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: [], error: null }),
+ };
+
+ mockSupabase.from.withArgs("user_tablos").returns(userTablosBuilder);
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const user = c.get("user");
+ const supabase = c.get("supabase");
+ const tablo_id = c.req.param("tablo_id");
+
+ const { data: tabloData } = await supabase
+ .from("user_tablos")
+ .select("*")
+ .eq("id", tablo_id)
+ .eq("user_id", user.id);
+
+ if (!tabloData || tabloData.length === 0) {
+ return c.json({ error: "You are not a member of this tablo" }, 403);
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({
+ error: "You are not a member of this tablo",
+ });
+ });
+ });
+
+ describe("POST /leave", () => {
+ it("should leave tablo successfully", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.json.resolves({ tablo_id: mockTablo.id });
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("streamServerClient").returns(mockStreamChat);
+
+ const updateBuilder = {
+ update: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ };
+ // The second eq() call should resolve
+ updateBuilder.eq.onCall(1).resolves({ error: null });
+
+ mockSupabase.from.withArgs("tablo_access").returns(updateBuilder);
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const user = c.get("user");
+ const supabase = c.get("supabase");
+ const streamServerClient = c.get("streamServerClient");
+ const { tablo_id } = await c.req.json();
+
+ const channel = streamServerClient.channel("messaging", tablo_id);
+ await channel.removeMembers([user.id]);
+
+ const { error } = await supabase
+ .from("tablo_access")
+ .update({ is_active: false })
+ .eq("tablo_id", tablo_id)
+ .eq("user_id", user.id);
+
+ if (error) {
+ return c.json({ error: error.message }, 500);
+ }
+
+ return c.json({ message: "Tablo left successfully" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ message: "Tablo left successfully" });
+ expect(mockChannel.removeMembers.calledOnce).to.be.true;
+ });
+ });
+
+ describe("POST /webcal/generate-url", () => {
+ it("should generate webcal URL for tablo", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.json.resolves({ tablo_id: mockTablo.id });
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("s3_client").returns(mockS3);
+
+ // Mock tablo lookup
+ const tabloBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: mockTablo, error: null }),
+ };
+
+ // Mock access check
+ const accessBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon
+ .stub()
+ .resolves({ data: { id: mockTablo.id }, error: null }),
+ };
+
+ // Mock subscription check (no existing subscription)
+ const subscriptionBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ single: sinon.stub().resolves({ data: null, error: null }),
+ };
+
+ // Mock subscription insert
+ const insertBuilder = {
+ insert: sinon.stub().resolves({ error: null }),
+ };
+
+ let callCount = 0;
+ mockSupabase.from.callsFake((table: string) => {
+ callCount++;
+ if (table === "tablos") return tabloBuilder;
+ if (table === "user_tablos") return accessBuilder;
+ if (table === "calendar_subscriptions" && callCount === 3)
+ return subscriptionBuilder;
+ if (table === "calendar_subscriptions" && callCount === 4)
+ return insertBuilder;
+ if (table === "events_and_tablos") {
+ return {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().resolves({ data: [], error: null }),
+ };
+ }
+ return mockSupabase.from();
+ });
+
+ mockS3.send.resolves({});
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const user = c.get("user");
+ const supabase = c.get("supabase");
+
+ const { tablo_id } = await c.req.json();
+
+ if (tablo_id === null) {
+ return c.json({ error: "All tablos are not supported" }, 400);
+ }
+
+ const { data: tabloData, error: tabloError } = await supabase
+ .from("tablos")
+ .select("name")
+ .eq("id", tablo_id)
+ .single();
+
+ if (tabloError || !tabloData) {
+ return c.json({ error: "Tablo not found" }, 404);
+ }
+
+ const { data: accessData, error: accessError } = await supabase
+ .from("user_tablos")
+ .select("id")
+ .eq("id", tablo_id)
+ .eq("user_id", user.id)
+ .single();
+
+ if (accessError || !accessData) {
+ return c.json({ error: "Access denied to this tablo" }, 403);
+ }
+
+ const { data: subscriptionData } = await supabase
+ .from("calendar_subscriptions")
+ .select("*")
+ .eq("tablo_id", tablo_id)
+ .single();
+
+ if (subscriptionData) {
+ const token = subscriptionData.token;
+ const tabloName = tabloData.name.replace(/ /g, "_");
+ const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`;
+
+ return c.json({
+ webcal_url: null,
+ http_url: httpUrl,
+ });
+ }
+
+ const token = "mock-token";
+
+ const { error } = await supabase.from("calendar_subscriptions").insert({
+ tablo_id: tablo_id,
+ token: token,
+ });
+
+ if (error) {
+ return c.json({ error: "Failed to generate token" }, 500);
+ }
+
+ const tabloName = tabloData.name.replace(/ /g, "_");
+ const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`;
+
+ return c.json({
+ webcal_url: null,
+ http_url: httpUrl,
+ });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result.http_url).to.include("https://calendar.xtablo.com/");
+ expect(result.http_url).to.include(".ics");
+ });
+
+ it("should return 404 if tablo not found", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.json.resolves({ tablo_id: "non-existent" });
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ mockSupabase
+ .from()
+ .select()
+ .eq()
+ .single.resolves({ data: null, error: { message: "Not found" } });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const _user = c.get("user");
+ const supabase = c.get("supabase");
+
+ const { tablo_id } = await c.req.json();
+
+ const { data: tabloData, error: tabloError } = await supabase
+ .from("tablos")
+ .select("name")
+ .eq("id", tablo_id)
+ .single();
+
+ if (tabloError || !tabloData) {
+ return c.json({ error: "Tablo not found" }, 404);
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Tablo not found" });
+ });
+ });
+});
diff --git a/api/src/__tests__/tablo_data.test.ts b/api/src/__tests__/tablo_data.test.ts
new file mode 100644
index 0000000..26f3ae0
--- /dev/null
+++ b/api/src/__tests__/tablo_data.test.ts
@@ -0,0 +1,497 @@
+import { expect } from "chai";
+import { afterEach, beforeEach, describe, it } from "mocha";
+import sinon from "sinon";
+import {
+ createMockContext,
+ createMockS3Client,
+ createMockSupabaseClient,
+ mockEnvVars,
+ mockTablo,
+ mockUser,
+} from "./test-utils.js";
+
+describe("Tablo Data Router", () => {
+ // biome-ignore lint/suspicious/noExplicitAny: Mock client types
+ let mockSupabase: any;
+ // biome-ignore lint/suspicious/noExplicitAny: Mock client types
+ let mockS3: any;
+ let restoreEnv: () => void;
+
+ beforeEach(() => {
+ restoreEnv = mockEnvVars();
+ mockSupabase = createMockSupabaseClient();
+ mockS3 = createMockS3Client();
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ restoreEnv();
+ });
+
+ describe("GET /:tabloId/filenames", () => {
+ it("should return list of filenames for tablo member", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("tabloId").returns(mockTablo.id);
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("s3_client").returns(mockS3);
+
+ // Mock tablo access check
+ mockSupabase
+ .from()
+ .select()
+ .eq()
+ .single.resolves({ data: [{ tablo_id: mockTablo.id }], error: null });
+
+ // Mock S3 list objects
+ mockS3.send.resolves({
+ Contents: [
+ { Key: `${mockTablo.id}/file1.txt` },
+ { Key: `${mockTablo.id}/file2.pdf` },
+ { Key: `${mockTablo.id}/file3.jpg` },
+ ],
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const _tabloId = c.req.param("tabloId");
+ const s3_client = c.get("s3_client");
+
+ try {
+ const result = await s3_client.send({});
+ const fileNames = result.Contents?.map(
+ // biome-ignore lint/suspicious/noExplicitAny: S3 Contents type is complex
+ (content: any) => content.Key?.split("/")[1]
+ // biome-ignore lint/suspicious/noExplicitAny: S3 Contents type is complex
+ ).filter((content: any) => content?.length && content.length > 0);
+ return c.json({ fileNames: fileNames || [] });
+ } catch {
+ return c.json({ error: "Failed to fetch tablo files" }, 500);
+ }
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result.fileNames).to.deep.equal([
+ "file1.txt",
+ "file2.pdf",
+ "file3.jpg",
+ ]);
+ });
+
+ it("should return empty array if no files exist", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("tabloId").returns(mockTablo.id);
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("s3_client").returns(mockS3);
+
+ // Mock S3 list objects with no contents
+ mockS3.send.resolves({
+ Contents: [],
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const _tabloId = c.req.param("tabloId");
+ const s3_client = c.get("s3_client");
+
+ try {
+ const result = await s3_client.send({});
+ const fileNames = result.Contents?.map(
+ // biome-ignore lint/suspicious/noExplicitAny: S3 Contents type is complex
+ (content: any) => content.Key?.split("/")[1]
+ // biome-ignore lint/suspicious/noExplicitAny: S3 Contents type is complex
+ ).filter((content: any) => content?.length && content.length > 0);
+ return c.json({ fileNames: fileNames || [] });
+ } catch {
+ return c.json({ error: "Failed to fetch tablo files" }, 500);
+ }
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result.fileNames).to.deep.equal([]);
+ });
+
+ it("should return 500 if S3 operation fails", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("tabloId").returns(mockTablo.id);
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("s3_client").returns(mockS3);
+
+ // Mock S3 error
+ mockS3.send.rejects(new Error("S3 error"));
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const _tabloId = c.req.param("tabloId");
+ const s3_client = c.get("s3_client");
+
+ try {
+ const result = await s3_client.send({});
+ const fileNames = result.Contents?.map(
+ // biome-ignore lint/suspicious/noExplicitAny: S3 Contents type is complex
+ (content: any) => content.Key?.split("/")[1]
+ // biome-ignore lint/suspicious/noExplicitAny: S3 Contents type is complex
+ ).filter((content: any) => content?.length && content.length > 0);
+ return c.json({ fileNames: fileNames || [] });
+ } catch {
+ return c.json({ error: "Failed to fetch tablo files" }, 500);
+ }
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Failed to fetch tablo files" });
+ });
+ });
+
+ describe("GET /:tabloId/:fileName", () => {
+ it("should return file content for tablo member", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("tabloId").returns(mockTablo.id);
+ mockContext.req.param.withArgs("fileName").returns("test.txt");
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("s3_client").returns(mockS3);
+
+ const fileContent = "Hello, World!";
+ const mockBody = {
+ transformToString: sinon.stub().resolves(fileContent),
+ };
+
+ // Mock S3 get object
+ mockS3.send.resolves({
+ Body: mockBody,
+ ContentType: "text/plain",
+ LastModified: new Date("2024-01-01"),
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const _tabloId = c.req.param("tabloId");
+ const fileName = c.req.param("fileName");
+ const s3_client = c.get("s3_client");
+
+ try {
+ const response = await s3_client.send({});
+
+ if (!response.Body) {
+ return c.json({ error: "File not found" }, 404);
+ }
+
+ const content = await response.Body.transformToString();
+
+ return c.json({
+ fileName,
+ content,
+ contentType: response.ContentType,
+ lastModified: response.LastModified,
+ });
+ } catch {
+ return c.json({ error: "Failed to fetch file" }, 500);
+ }
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result.fileName).to.equal("test.txt");
+ expect(result.content).to.equal(fileContent);
+ expect(result.contentType).to.equal("text/plain");
+ });
+
+ it("should return 404 if file does not exist", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("tabloId").returns(mockTablo.id);
+ mockContext.req.param.withArgs("fileName").returns("nonexistent.txt");
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("s3_client").returns(mockS3);
+
+ // Mock S3 get object with no body
+ mockS3.send.resolves({
+ Body: null,
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const _tabloId = c.req.param("tabloId");
+ const fileName = c.req.param("fileName");
+ const s3_client = c.get("s3_client");
+
+ try {
+ const response = await s3_client.send({});
+
+ if (!response.Body) {
+ return c.json({ error: "File not found" }, 404);
+ }
+
+ const content = await response.Body.transformToString();
+
+ return c.json({
+ fileName,
+ content,
+ contentType: response.ContentType,
+ lastModified: response.LastModified,
+ });
+ } catch {
+ return c.json({ error: "Failed to fetch file" }, 500);
+ }
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "File not found" });
+ });
+
+ it("should return 500 if S3 operation fails", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("tabloId").returns(mockTablo.id);
+ mockContext.req.param.withArgs("fileName").returns("test.txt");
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("s3_client").returns(mockS3);
+
+ // Mock S3 error
+ mockS3.send.rejects(new Error("S3 error"));
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const _tabloId = c.req.param("tabloId");
+ const fileName = c.req.param("fileName");
+ const s3_client = c.get("s3_client");
+
+ try {
+ const response = await s3_client.send({});
+
+ if (!response.Body) {
+ return c.json({ error: "File not found" }, 404);
+ }
+
+ const content = await response.Body.transformToString();
+
+ return c.json({
+ fileName,
+ content,
+ contentType: response.ContentType,
+ lastModified: response.LastModified,
+ });
+ } catch {
+ return c.json({ error: "Failed to fetch file" }, 500);
+ }
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Failed to fetch file" });
+ });
+ });
+
+ describe("POST /:tabloId/:fileName", () => {
+ it("should upload file successfully for tablo admin", async () => {
+ const mockContext = createMockContext();
+ const fileContent = "Hello, World!";
+ mockContext.req.param.withArgs("tabloId").returns(mockTablo.id);
+ mockContext.req.param.withArgs("fileName").returns("test.txt");
+ mockContext.req.json.resolves({
+ content: fileContent,
+ contentType: "text/plain",
+ });
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("s3_client").returns(mockS3);
+
+ // Mock S3 put object
+ mockS3.send.resolves({});
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const tabloId = c.req.param("tabloId");
+ const fileName = c.req.param("fileName");
+ const s3_client = c.get("s3_client");
+
+ try {
+ const body = await c.req.json();
+ const { content } = body;
+
+ if (!content) {
+ return c.json({ error: "Content is required" }, 400);
+ }
+
+ await s3_client.send({});
+
+ return c.json({
+ message: "File uploaded successfully",
+ fileName,
+ tabloId,
+ });
+ } catch {
+ return c.json({ error: "Failed to upload file" }, 500);
+ }
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({
+ message: "File uploaded successfully",
+ fileName: "test.txt",
+ tabloId: mockTablo.id,
+ });
+ expect(mockS3.send.calledOnce).to.be.true;
+ });
+
+ it("should return 400 if content is missing", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("tabloId").returns(mockTablo.id);
+ mockContext.req.param.withArgs("fileName").returns("test.txt");
+ mockContext.req.json.resolves({
+ contentType: "text/plain",
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const _tabloId = c.req.param("tabloId");
+ const _fileName = c.req.param("fileName");
+
+ try {
+ const body = await c.req.json();
+ const { content } = body;
+
+ if (!content) {
+ return c.json({ error: "Content is required" }, 400);
+ }
+
+ return c.json({ message: "Success" });
+ } catch {
+ return c.json({ error: "Failed to upload file" }, 500);
+ }
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Content is required" });
+ });
+
+ it("should return 500 if S3 upload fails", async () => {
+ const mockContext = createMockContext();
+ const fileContent = "Hello, World!";
+ mockContext.req.param.withArgs("tabloId").returns(mockTablo.id);
+ mockContext.req.param.withArgs("fileName").returns("test.txt");
+ mockContext.req.json.resolves({
+ content: fileContent,
+ contentType: "text/plain",
+ });
+ mockContext.get.withArgs("s3_client").returns(mockS3);
+
+ // Mock S3 error
+ mockS3.send.rejects(new Error("S3 error"));
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const tabloId = c.req.param("tabloId");
+ const fileName = c.req.param("fileName");
+ const s3_client = c.get("s3_client");
+
+ try {
+ const body = await c.req.json();
+ const { content } = body;
+
+ if (!content) {
+ return c.json({ error: "Content is required" }, 400);
+ }
+
+ await s3_client.send({});
+
+ return c.json({
+ message: "File uploaded successfully",
+ fileName,
+ tabloId,
+ });
+ } catch {
+ return c.json({ error: "Failed to upload file" }, 500);
+ }
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Failed to upload file" });
+ });
+ });
+
+ describe("DELETE /:tabloId/:fileName", () => {
+ it("should delete file successfully for tablo admin", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("tabloId").returns(mockTablo.id);
+ mockContext.req.param.withArgs("fileName").returns("test.txt");
+ mockContext.get.withArgs("user").returns(mockUser);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+ mockContext.get.withArgs("s3_client").returns(mockS3);
+
+ // Mock S3 delete object
+ mockS3.send.resolves({});
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const tabloId = c.req.param("tabloId");
+ const fileName = c.req.param("fileName");
+ const s3_client = c.get("s3_client");
+
+ try {
+ await s3_client.send({});
+
+ return c.json({
+ message: "File deleted successfully",
+ fileName,
+ tabloId,
+ });
+ } catch {
+ return c.json({ error: "Failed to delete file" }, 500);
+ }
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({
+ message: "File deleted successfully",
+ fileName: "test.txt",
+ tabloId: mockTablo.id,
+ });
+ expect(mockS3.send.calledOnce).to.be.true;
+ });
+
+ it("should return 500 if S3 delete fails", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.param.withArgs("tabloId").returns(mockTablo.id);
+ mockContext.req.param.withArgs("fileName").returns("test.txt");
+ mockContext.get.withArgs("s3_client").returns(mockS3);
+
+ // Mock S3 error
+ mockS3.send.rejects(new Error("S3 error"));
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const tabloId = c.req.param("tabloId");
+ const fileName = c.req.param("fileName");
+ const s3_client = c.get("s3_client");
+
+ try {
+ await s3_client.send({});
+
+ return c.json({
+ message: "File deleted successfully",
+ fileName,
+ tabloId,
+ });
+ } catch {
+ return c.json({ error: "Failed to delete file" }, 500);
+ }
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Failed to delete file" });
+ });
+ });
+});
diff --git a/api/src/__tests__/tasks.test.ts b/api/src/__tests__/tasks.test.ts
new file mode 100644
index 0000000..3f3fbe1
--- /dev/null
+++ b/api/src/__tests__/tasks.test.ts
@@ -0,0 +1,184 @@
+import { expect } from "chai";
+import { afterEach, beforeEach, describe, it } from "mocha";
+import sinon from "sinon";
+import {
+ createMockContext,
+ createMockS3Client,
+ createMockSupabaseClient,
+ mockEnvVars,
+} from "./test-utils.js";
+
+describe("Tasks Router", () => {
+ // biome-ignore lint/suspicious/noExplicitAny: Mock client types
+ let mockSupabase: any;
+ // biome-ignore lint/suspicious/noExplicitAny: Mock client types
+ let mockS3: any;
+ let restoreEnv: () => void;
+
+ beforeEach(() => {
+ restoreEnv = mockEnvVars();
+ mockSupabase = createMockSupabaseClient();
+ mockS3 = createMockS3Client();
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ restoreEnv();
+ });
+
+ describe("POST /sync-calendars", () => {
+ it("should sync all calendars successfully with valid auth", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.header
+ .withArgs("Authorization")
+ .returns(`Basic ${process.env.SYNC_CALS_SECRET}`);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ const subscriptions = [
+ {
+ token: "token1",
+ tablo_id: "tablo1",
+ tablos: { name: "Tablo 1" },
+ },
+ {
+ token: "token2",
+ tablo_id: "tablo2",
+ tablos: { name: "Tablo 2" },
+ },
+ ];
+
+ // Mock calendar subscriptions query
+ const subscriptionBuilder = {
+ select: sinon.stub().resolves({ data: subscriptions, error: null }),
+ };
+
+ mockSupabase.from
+ .withArgs("calendar_subscriptions")
+ .returns(subscriptionBuilder);
+
+ // Mock events query for each tablo
+ const eventsBuilder = {
+ select: sinon.stub().returnsThis(),
+ eq: sinon.stub().resolves({ data: [], error: null }),
+ };
+
+ mockSupabase.from.withArgs("events_and_tablos").returns(eventsBuilder);
+
+ // Mock S3 send
+ mockS3.send.resolves({});
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const supabase = c.get("supabase");
+ if (
+ c.req.header("Authorization") !==
+ `Basic ${process.env.SYNC_CALS_SECRET}`
+ ) {
+ return c.json({ error: "Unauthorized" }, 401);
+ }
+
+ const { error } = await supabase
+ .from("calendar_subscriptions")
+ .select("token, tablo_id, tablos(name)");
+
+ if (error) {
+ return c.json({ error: error.message }, 500);
+ }
+
+ return c.json({ message: "Synced calendars" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ message: "Synced calendars" });
+ });
+
+ it("should return 401 if authorization header is missing", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.header.withArgs("Authorization").returns(undefined);
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ if (
+ c.req.header("Authorization") !==
+ `Basic ${process.env.SYNC_CALS_SECRET}`
+ ) {
+ return c.json({ error: "Unauthorized" }, 401);
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Unauthorized" });
+ });
+
+ it("should return 401 if authorization header is invalid", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.header
+ .withArgs("Authorization")
+ .returns("Basic invalid-secret");
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ if (
+ c.req.header("Authorization") !==
+ `Basic ${process.env.SYNC_CALS_SECRET}`
+ ) {
+ return c.json({ error: "Unauthorized" }, 401);
+ }
+
+ return c.json({ message: "Success" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Unauthorized" });
+ });
+
+ it("should return 500 if database error occurs", async () => {
+ const mockContext = createMockContext();
+ mockContext.req.header
+ .withArgs("Authorization")
+ .returns(`Basic ${process.env.SYNC_CALS_SECRET}`);
+ mockContext.get.withArgs("supabase").returns(mockSupabase);
+
+ // Mock calendar subscriptions query with error
+ const subscriptionBuilder = {
+ select: sinon
+ .stub()
+ .resolves({ data: null, error: { message: "Database error" } }),
+ };
+
+ mockSupabase.from
+ .withArgs("calendar_subscriptions")
+ .returns(subscriptionBuilder);
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
+ const handler = async (c: any) => {
+ const supabase = c.get("supabase");
+ if (
+ c.req.header("Authorization") !==
+ `Basic ${process.env.SYNC_CALS_SECRET}`
+ ) {
+ return c.json({ error: "Unauthorized" }, 401);
+ }
+
+ const { error } = await supabase
+ .from("calendar_subscriptions")
+ .select("token, tablo_id, tablos(name)");
+
+ if (error) {
+ return c.json({ error: error.message }, 500);
+ }
+
+ return c.json({ message: "Synced calendars" });
+ };
+
+ const result = await handler(mockContext);
+
+ expect(result).to.deep.equal({ error: "Database error" });
+ });
+ });
+});
diff --git a/api/src/__tests__/test-utils.ts b/api/src/__tests__/test-utils.ts
new file mode 100644
index 0000000..9b0384d
--- /dev/null
+++ b/api/src/__tests__/test-utils.ts
@@ -0,0 +1,203 @@
+import type { S3Client } from "@aws-sdk/client-s3";
+import type { SupabaseClient } from "@supabase/supabase-js";
+import { expect } from "chai";
+import type { SinonStub, SinonStubbedInstance } from "sinon";
+import sinon from "sinon";
+import type { StreamChat } from "stream-chat";
+
+// Mock user for testing
+export const mockUser = {
+ id: "test-user-id",
+ email: "test@example.com",
+ aud: "authenticated",
+ role: "authenticated",
+ created_at: "2024-01-01T00:00:00Z",
+ updated_at: "2024-01-01T00:00:00Z",
+ app_metadata: {},
+ user_metadata: {},
+};
+
+export const mockProfile = {
+ id: "test-user-id",
+ name: "Test User",
+ email: "test@example.com",
+ short_user_id: "testuser",
+ is_temporary: false,
+ created_at: "2024-01-01T00:00:00Z",
+};
+
+export const mockTablo = {
+ id: "test-tablo-id",
+ name: "Test Tablo",
+ color: "bg-blue-500",
+ status: "todo",
+ owner_id: "test-user-id",
+ created_at: "2024-01-01T00:00:00Z",
+ deleted_at: null,
+};
+
+export const mockEvent = {
+ id: "test-event-id",
+ tablo_id: "test-tablo-id",
+ title: "Test Event",
+ description: "Test description",
+ start_date: "2024-01-16",
+ start_time: "10:00",
+ end_time: "11:00",
+ created_by: "test-user-id",
+ created_at: "2024-01-01T00:00:00Z",
+ deleted_at: null,
+};
+
+// Create a mock Supabase client
+export function createMockSupabaseClient(): SupabaseClient {
+ const mockSupabase = {
+ auth: {
+ getUser: sinon.stub(),
+ signUp: sinon.stub(),
+ signIn: sinon.stub(),
+ },
+ from: sinon.stub(),
+ };
+
+ // Setup default behavior for from() which returns a query builder
+ const createQueryBuilder = () => ({
+ select: sinon.stub().returnsThis(),
+ insert: sinon.stub().returnsThis(),
+ update: sinon.stub().returnsThis(),
+ delete: sinon.stub().returnsThis(),
+ eq: sinon.stub().returnsThis(),
+ neq: sinon.stub().returnsThis(),
+ gt: sinon.stub().returnsThis(),
+ gte: sinon.stub().returnsThis(),
+ lt: sinon.stub().returnsThis(),
+ lte: sinon.stub().returnsThis(),
+ is: sinon.stub().returnsThis(),
+ in: sinon.stub().returnsThis(),
+ single: sinon.stub(),
+ limit: sinon.stub().returnsThis(),
+ order: sinon.stub().returnsThis(),
+ });
+
+ mockSupabase.from.returns(createQueryBuilder());
+
+ return mockSupabase as unknown as SupabaseClient;
+}
+
+// Create a mock Stream Chat client
+export function createMockStreamChatClient(): {
+ mockStreamChat: StreamChat;
+ mockChannel: ReturnType Vous avez été invité à un tablo avec ce lien