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; +} { + const mockChannel = { + create: sinon.stub().resolves(), + update: sinon.stub().resolves(), + delete: sinon.stub().resolves(), + addMembers: sinon.stub().resolves(), + removeMembers: sinon.stub().resolves(), + sendMessage: sinon.stub().resolves(), + }; + + const mockStreamChat = { + upsertUser: sinon.stub().resolves(), + createToken: sinon.stub().returns("mock-stream-token"), + channel: sinon.stub().returns(mockChannel), + }; + + return { + mockStreamChat: mockStreamChat as unknown as StreamChat, + mockChannel: mockChannel as unknown as ReturnType, + }; +} + +// Create a mock S3 client +export function createMockS3Client(): S3Client { + const mockS3 = { + send: sinon.stub(), + }; + + return mockS3 as unknown as S3Client; +} + +// Create a mock transporter +export function createMockTransporter(): { sendMail: SinonStub } { + return { + sendMail: sinon.stub().resolves({ messageId: "mock-message-id" }), + }; +} + +// Helper to create a mock Hono context +export function createMockContext(overrides: Record = {}) { + const context = { + req: { + json: sinon.stub(), + header: sinon.stub(), + param: sinon.stub(), + }, + json: sinon.stub().returnsArg(0), + get: sinon.stub(), + set: sinon.stub(), + ...overrides, + }; + + // biome-ignore lint/suspicious/noExplicitAny: Mock context needs flexibility + return context as any; +} + +// Helper to create a mock next function +export function createMockNext() { + return sinon.stub().resolves(); +} + +// Helper to reset all stubs +// biome-ignore lint/suspicious/noExplicitAny: Flexible stub reset utility +export function resetAllStubs(...stubs: any[]) { + stubs.forEach((stub) => { + if (stub && typeof stub.reset === "function") { + stub.reset(); + } else if (stub && typeof stub === "object") { + // biome-ignore lint/suspicious/noExplicitAny: Need to check nested values + Object.values(stub).forEach((value: any) => { + if (value && typeof value.reset === "function") { + value.reset(); + } + }); + } + }); +} + +// Helper to verify stub was called with specific args +// biome-ignore lint/suspicious/noExplicitAny: Flexible argument checking +export function assertCalledWith(stub: SinonStub, ...args: any[]) { + expect(stub.calledWith(...args)).to.be.true; +} + +// Helper to verify stub was called once +export function assertCalledOnce(stub: SinonStub) { + expect(stub.calledOnce).to.be.true; +} + +// Helper to verify stub was not called +export function assertNotCalled(stub: SinonStub) { + expect(stub.called).to.be.false; +} + +// Mock environment variables +export function mockEnvVars() { + const originalEnv = { ...process.env }; + + process.env.SUPABASE_URL = "https://test.supabase.co"; + process.env.SUPABASE_SERVICE_ROLE_KEY = "test-service-role-key"; + process.env.STREAM_CHAT_API_KEY = "test-stream-key"; + process.env.STREAM_CHAT_API_SECRET = "test-stream-secret"; + process.env.R2_ACCOUNT_ID = "test-r2-account"; + process.env.R2_ACCESS_KEY_ID = "test-r2-access-key"; + process.env.R2_SECRET_ACCESS_KEY = "test-r2-secret"; + process.env.NODE_ENV = "test"; + process.env.FRONTEND_URL = "https://app.test.com"; + process.env.SYNC_CALS_SECRET = "test-sync-secret"; + + return () => { + process.env = originalEnv; + }; +} diff --git a/api/src/__tests__/user.test.ts b/api/src/__tests__/user.test.ts new file mode 100644 index 0000000..ebce978 --- /dev/null +++ b/api/src/__tests__/user.test.ts @@ -0,0 +1,337 @@ +import { expect } from "chai"; +import { Hono } from "hono"; +import { afterEach, beforeEach, describe, it } from "mocha"; +import sinon from "sinon"; +import { userRouter } from "../user.js"; +import { + createMockContext, + createMockNext, + createMockStreamChatClient, + createMockSupabaseClient, + createMockTransporter, + mockEnvVars, + mockProfile, + mockUser, + resetAllStubs, +} from "./test-utils.js"; + +describe("User Router", () => { + // biome-ignore lint/suspicious/noExplicitAny: Mock client types + let mockSupabase: any; + // biome-ignore lint/suspicious/noExplicitAny: Mock client types + let mockStreamChat: any; + let restoreEnv: () => void; + + beforeEach(() => { + restoreEnv = mockEnvVars(); + mockSupabase = createMockSupabaseClient(); + const streamMocks = createMockStreamChatClient(); + mockStreamChat = streamMocks.mockStreamChat; + }); + + afterEach(() => { + sinon.restore(); + restoreEnv(); + }); + + describe("POST /sign-up-to-stream", () => { + it("should successfully sign up user to Stream Chat", async () => { + const mockContext = createMockContext(); + mockContext.get.withArgs("user").returns(mockUser); + mockContext.get.withArgs("supabase").returns(mockSupabase); + mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); + + // Mock Supabase response + mockSupabase + .from() + .select() + .eq() + .single.resolves({ data: mockProfile, error: null }); + + // Create a test handler + // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context + const handler = async (c: any) => { + const { id } = c.get("user"); + const supabase = c.get("supabase"); + + const { data } = await supabase + .from("profiles") + .select("*") + .eq("id", id) + .single(); + + const streamServerClient = c.get("streamServerClient"); + await streamServerClient.upsertUser({ + id, + name: data.name ?? "", + language: "fr", + }); + + return c.json({ + message: "User signed up to stream", + }); + }; + + const result = await handler(mockContext); + + expect(mockStreamChat.upsertUser.calledOnce).to.be.true; + expect( + mockStreamChat.upsertUser.calledWith({ + id: mockUser.id, + name: mockProfile.name, + language: "fr", + }) + ).to.be.true; + expect(result).to.deep.equal({ message: "User signed up to stream" }); + }); + }); + + describe("GET /me", () => { + it("should return user profile with Stream token", async () => { + const mockContext = createMockContext(); + mockContext.get.withArgs("user").returns(mockUser); + mockContext.get.withArgs("supabase").returns(mockSupabase); + mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); + + // Mock Supabase response + mockSupabase + .from() + .select() + .eq() + .single.resolves({ data: mockProfile, error: null }); + + // Create a 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 streamServerClient = c.get("streamServerClient"); + + const { data, error } = await supabase + .from("profiles") + .select("*") + .eq("id", user.id) + .single(); + + if (!data) { + return c.json({ error: "User not found" }, 404); + } + + if (error) { + return c.json({ error: error.message }, 500); + } + + const user_id = data.id; + const token = streamServerClient.createToken(user_id); + + return c.json({ + ...data, + streamToken: token, + }); + }; + + const result = await handler(mockContext); + + expect(mockStreamChat.createToken.calledOnce).to.be.true; + expect(mockStreamChat.createToken.calledWith(mockUser.id)).to.be.true; + expect(result).to.deep.equal({ + ...mockProfile, + streamToken: "mock-stream-token", + }); + }); + + it("should return 404 if user profile not found", async () => { + const mockContext = createMockContext(); + mockContext.get.withArgs("user").returns(mockUser); + mockContext.get.withArgs("supabase").returns(mockSupabase); + mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); + + // Mock Supabase response with no data + mockSupabase + .from() + .select() + .eq() + .single.resolves({ data: null, error: null }); + + // Create a 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 streamServerClient = c.get("streamServerClient"); + + const { data, error } = await supabase + .from("profiles") + .select("*") + .eq("id", user.id) + .single(); + + if (!data) { + return c.json({ error: "User not found" }, 404); + } + + if (error) { + return c.json({ error: error.message }, 500); + } + + const user_id = data.id; + const token = streamServerClient.createToken(user_id); + + return c.json({ + ...data, + streamToken: token, + }); + }; + + const result = await handler(mockContext); + + expect(result).to.deep.equal({ error: "User not found" }); + }); + + it("should return 500 if database error occurs", async () => { + const mockContext = createMockContext(); + mockContext.get.withArgs("user").returns(mockUser); + mockContext.get.withArgs("supabase").returns(mockSupabase); + mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); + + // Mock Supabase response with error + mockSupabase + .from() + .select() + .eq() + .single.resolves({ + data: mockProfile, + error: { message: "Database error" }, + }); + + // Create a 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 streamServerClient = c.get("streamServerClient"); + + const { data, error } = await supabase + .from("profiles") + .select("*") + .eq("id", user.id) + .single(); + + if (!data) { + return c.json({ error: "User not found" }, 404); + } + + if (error) { + return c.json({ error: error.message }, 500); + } + + const user_id = data.id; + const token = streamServerClient.createToken(user_id); + + return c.json({ + ...data, + streamToken: token, + }); + }; + + const result = await handler(mockContext); + + expect(result).to.deep.equal({ error: "Database error" }); + }); + }); + + describe("POST /mark-temporary", () => { + it("should mark user as temporary and send email", async () => { + const mockContext = createMockContext(); + mockContext.req.json.resolves({ temporary_password: "temp123" }); + mockContext.get.withArgs("user").returns(mockUser); + mockContext.get.withArgs("supabase").returns(mockSupabase); + + // Mock Supabase update response + mockSupabase + .from() + .update() + .eq() + .select() + .single.resolves({ + data: { ...mockProfile, is_temporary: true }, + error: null, + }); + + // Create a 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"); + + await c.req.json(); + + const { error } = await supabase + .from("profiles") + .update({ + is_temporary: true, + }) + .eq("id", user.id) + .select() + .single(); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ + message: "User marked as temporary", + }); + }; + + const result = await handler(mockContext); + + expect(result).to.deep.equal({ message: "User marked as temporary" }); + }); + + it("should return 500 if database update fails", async () => { + const mockContext = createMockContext(); + mockContext.req.json.resolves({ temporary_password: "temp123" }); + mockContext.get.withArgs("user").returns(mockUser); + mockContext.get.withArgs("supabase").returns(mockSupabase); + + // Mock Supabase error response + mockSupabase + .from() + .update() + .eq() + .select() + .single.resolves({ data: null, error: { message: "Update failed" } }); + + // Create a 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"); + + await c.req.json(); + + const { error } = await supabase + .from("profiles") + .update({ + is_temporary: true, + }) + .eq("id", user.id) + .select() + .single(); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ + message: "User marked as temporary", + }); + }; + + const result = await handler(mockContext); + + expect(result).to.deep.equal({ error: "Update failed" }); + }); + }); +}); diff --git a/api/src/helpers.ts b/api/src/helpers.ts index cd0f596..9599325 100644 --- a/api/src/helpers.ts +++ b/api/src/helpers.ts @@ -1,4 +1,3 @@ -import type { EventAndTablo } from "./types.ts"; import { GetObjectCommand, ListObjectsCommand, @@ -7,6 +6,7 @@ import { S3Client, } from "@aws-sdk/client-s3"; import type { SupabaseClient } from "@supabase/supabase-js"; +import type { EventAndTablo } from "./types.ts"; export const generateICSFromEvents = ( events: EventAndTablo[], @@ -15,7 +15,7 @@ export const generateICSFromEvents = ( const formatDate = (date: string, time: string) => { // Combine date (YYYY-MM-DD) and time (HH:MM:SS) into ISO format then convert to UTC const dateTime = new Date(`${date}T${time}`); - return dateTime.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; + return `${dateTime.toISOString().replace(/[-:]/g, "").split(".")[0]}Z`; }; const escapeICSText = (text: string) => { @@ -56,19 +56,23 @@ export const generateICSFromEvents = ( `DTSTART:${startDateTime}`, `DTEND:${endDateTime}`, `SUMMARY:${escapeICSText(event.title)}`, - `DESCRIPTION:${escapeICSText(`Tablo: ${event.tablo_name}\n${event.description || ""}`)}`, + `DESCRIPTION:${escapeICSText( + `Tablo: ${event.tablo_name}\n${event.description || ""}` + )}`, event.tablo_name ? `CATEGORIES:${escapeICSText(event.tablo_name)}` : "", `CREATED:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`, - `LAST-MODIFIED:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`, + `LAST-MODIFIED:${ + new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + }Z`, "STATUS:CONFIRMED", "TRANSP:OPAQUE", "END:VEVENT", ].filter((line) => line !== ""); // Remove empty lines - icsContent += "\r\n" + eventLines.join("\r\n"); + icsContent += `\r\n${eventLines.join("\r\n")}`; }); - icsContent += "\r\n" + "END:VCALENDAR"; + icsContent += "\r\nEND:VCALENDAR"; return icsContent; }; @@ -110,7 +114,10 @@ export const writeCalendarFileToR2 = async ( ); }; -export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) => { +export const getTabloFileNames = async ( + s3_client: S3Client, + tabloId: string +) => { const bucketName = "tablo-data"; const { Contents } = await s3_client.send( @@ -125,7 +132,11 @@ export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) => ); }; -export const isTabloMember = async (supabase: SupabaseClient, tabloId: string, userId: string) => { +export const isTabloMember = async ( + supabase: SupabaseClient, + tabloId: string, + userId: string +) => { const { data: tabloAccess, error: isMemberError } = await supabase .from("tablo_access") .select("*") @@ -140,7 +151,11 @@ export const isTabloMember = async (supabase: SupabaseClient, tabloId: string, u return tabloAccess?.length > 0; }; -export const isTabloAdmin = async (supabase: SupabaseClient, tabloId: string, userId: string) => { +export const isTabloAdmin = async ( + supabase: SupabaseClient, + tabloId: string, + userId: string +) => { const { data: tabloAccess, error: isAdminError } = await supabase .from("tablo_access") .select("*") diff --git a/api/src/index.ts b/api/src/index.ts index bf44103..9f4719a 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,14 +1,13 @@ -import { Hono } from "hono"; import { serve } from "@hono/node-server"; -import { logger } from "hono/logger"; -import { mainRouter } from "./routers.js"; - -import { cors } from "hono/cors"; -import { config } from "./config.js"; import { run } from "graphile-worker"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { logger } from "hono/logger"; import path from "path"; import { fileURLToPath } from "url"; +import { config } from "./config.js"; import { publicRouter } from "./public.js"; +import { mainRouter } from "./routers.js"; const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file const __dirname = path.dirname(__filename); // get the name of the directory diff --git a/api/src/public.ts b/api/src/public.ts index 43ca975..43604c0 100644 --- a/api/src/public.ts +++ b/api/src/public.ts @@ -1,15 +1,15 @@ -import { Hono } from "hono"; import type { SupabaseClient } from "@supabase/supabase-js"; -import { supabaseMiddleware } from "./middleware.js"; +import { Hono } from "hono"; import type { Database, Tables } from "./database.types.js"; +import { supabaseMiddleware } from "./middleware.js"; import { + type EventTypeConfig, + type Exception, generateTimeSlots, getDateString, getDayOfWeek, type TimeSlot, type WeeklyAvailability, - type Exception, - type EventTypeConfig, } from "./slots.js"; // Helper function to get current time in CET diff --git a/api/src/routers.ts b/api/src/routers.ts index 8bb525a..e2738ad 100644 --- a/api/src/routers.ts +++ b/api/src/routers.ts @@ -1,9 +1,9 @@ import { Hono } from "hono"; -import { userRouter } from "./user.js"; import { supabaseMiddleware } from "./middleware.js"; import { tabloRouter } from "./tablo.js"; -import { taskRouter } from "./tasks.js"; import { tabloDataRouter } from "./tablo_data.js"; +import { taskRouter } from "./tasks.js"; +import { userRouter } from "./user.js"; export const mainRouter = new Hono<{ Bindings: { diff --git a/api/src/slots.ts b/api/src/slots.ts index 73ca0a9..b509129 100644 --- a/api/src/slots.ts +++ b/api/src/slots.ts @@ -2,9 +2,6 @@ import type { Tables } from "./database.types.js"; // Helper function to convert UTC date to CET function convertToCET(utcDate: Date): Date { - // Create a new date object to avoid mutating the original - const cetDate = new Date(utcDate); - // Use Intl.DateTimeFormat to get the correct CET/CEST offset const formatter = new Intl.DateTimeFormat("en", { timeZone: "Europe/Paris", @@ -19,7 +16,8 @@ function convertToCET(utcDate: Date): Date { const parts = formatter.formatToParts(utcDate); const year = parseInt(parts.find((p) => p.type === "year")?.value || "0"); - const month = parseInt(parts.find((p) => p.type === "month")?.value || "0") - 1; // Month is 0-indexed + const month = + parseInt(parts.find((p) => p.type === "month")?.value || "0") - 1; // Month is 0-indexed const day = parseInt(parts.find((p) => p.type === "day")?.value || "0"); const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0"); const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0"); @@ -83,7 +81,9 @@ function parseTime(timeStr: string): { hours: number; minutes: number } { } function formatTime(hours: number, minutes: number): string { - return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; + return `${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}`; } function addMinutes(timeStr: string, minutesToAdd: number): string { @@ -102,15 +102,13 @@ function addMinutes(timeStr: string, minutesToAdd: number): string { return formatTime(newHours, newMinutes); } -function isTimeInRange(time: string, range: TimeRange): boolean { - return time >= range.start && time <= range.end; -} - function mergeOverlappingTimeRanges(ranges: TimeRange[]): TimeRange[] { if (ranges.length <= 1) return ranges; // Sort ranges by start time - const sortedRanges = [...ranges].sort((a, b) => a.start.localeCompare(b.start)); + const sortedRanges = [...ranges].sort((a, b) => + a.start.localeCompare(b.start) + ); const merged: TimeRange[] = [sortedRanges[0]]; for (let i = 1; i < sortedRanges.length; i++) { @@ -120,7 +118,8 @@ function mergeOverlappingTimeRanges(ranges: TimeRange[]): TimeRange[] { // Check if current range overlaps with the last merged range if (current.start <= lastMerged.end) { // Merge by extending the end time if current range extends further - lastMerged.end = current.end > lastMerged.end ? current.end : lastMerged.end; + lastMerged.end = + current.end > lastMerged.end ? current.end : lastMerged.end; } else { // No overlap, add current range to merged array merged.push(current); @@ -210,7 +209,10 @@ export function generateTimeSlots( } // Check minimum advance booking - const minAdvanceBooking = getMinAdvanceBookingDate(eventTypeConfig, currentTime); + const minAdvanceBooking = getMinAdvanceBookingDate( + eventTypeConfig, + currentTime + ); // Generate slots for each time range for (const range of timeRanges) { @@ -221,13 +223,17 @@ export function generateTimeSlots( const endMinutes = endTime.hours * 60 + endTime.minutes; while (currentMinutes + eventTypeConfig.duration <= endMinutes) { - const slotTime = formatTime(Math.floor(currentMinutes / 60), currentMinutes % 60); + const slotTime = formatTime( + Math.floor(currentMinutes / 60), + currentMinutes % 60 + ); // Check if slot is in the future (considering minimum advance booking) // Compare dates first, then times if on the same date const isInFuture = dateStr > minAdvanceBooking.date || - (dateStr === minAdvanceBooking.date && slotTime >= minAdvanceBooking.time); + (dateStr === minAdvanceBooking.date && + slotTime >= minAdvanceBooking.time); slots.push({ date: dateStr, @@ -253,7 +259,8 @@ export function generateTimeSlots( if (event.start_date !== dateStr || event.deleted_at) return false; const eventStart = event.start_time; - const eventEnd = event.end_time || addMinutes(eventStart, eventTypeConfig.duration); + const eventEnd = + event.end_time || addMinutes(eventStart, eventTypeConfig.duration); // Apply buffer time around the existing event const bufferedEventStart = addMinutes(eventStart, -bufferTime); diff --git a/api/src/tablo.ts b/api/src/tablo.ts index 0bbf7ba..ade1b99 100644 --- a/api/src/tablo.ts +++ b/api/src/tablo.ts @@ -1,27 +1,15 @@ +import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3"; +import { PostgrestError, type SupabaseClient, type User } from "@supabase/supabase-js"; import { Hono } from "hono"; -import { - authMiddleware, - r2Middleware, - streamChatMiddleware, -} from "./middleware.js"; -import { - PostgrestError, - type SupabaseClient, - type User, -} from "@supabase/supabase-js"; import type { Transporter } from "nodemailer"; -import { generateToken } from "./token.js"; +import type { StreamChat } from "stream-chat"; import { config } from "./config.js"; import type { Tables } from "./database.types.ts"; -import type { StreamChat } from "stream-chat"; -import type { - TabloInsert, - EventInsertInTablo, - EventAndTablo, -} from "./types.ts"; -import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3"; import { generateICSFromEvents, writeCalendarFileToR2 } from "./helpers.js"; +import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js"; +import { generateToken } from "./token.js"; import { transporter } from "./transporter.js"; +import type { EventAndTablo, EventInsertInTablo, TabloInsert } from "./types.ts"; export const tabloRouter = new Hono<{ Variables: { @@ -178,9 +166,7 @@ tabloRouter.post("/create-and-invite", async (c) => { const { data: insertedTablo, error } = await supabase .from("tablos") .insert({ - name: `${invitedUserDataTyped.name || "Invité"} / ${ - ownerDataTyped.name || "Propriétaire" - }`, + name: `${invitedUserDataTyped.name || "Invité"} / ${ownerDataTyped.name || "Propriétaire"}`, color: "bg-blue-500", status: "todo", owner_id: ownerId, @@ -198,22 +184,20 @@ tabloRouter.post("/create-and-invite", async (c) => { } // Grant access to the current user (invited user) as a non-admin member - const { error: tabloAccessError } = await supabase - .from("tablo_access") - .insert( - { - tablo_id: tabloData.id, - user_id: user.id, - // ** IMPORTANT ** - is_admin: false, - // ------------- - is_active: true, - granted_by: ownerId, - } - // { - // onConflict: "tablo_id, user_id", - // } - ); + const { error: tabloAccessError } = await supabase.from("tablo_access").insert( + { + tablo_id: tabloData.id, + user_id: user.id, + // ** IMPORTANT ** + is_admin: false, + // ------------- + is_active: true, + granted_by: ownerId, + } + // { + // onConflict: "tablo_id, user_id", + // } + ); if (tabloAccessError) { console.error("tabloAccessError", tabloAccessError); @@ -306,8 +290,7 @@ tabloRouter.patch("/update", async (c) => { const updatedTablo = update as Tables<"tablos">; - const isUpdatingName = - tablo.name !== undefined && tablo.name !== updatedTablo.name; + const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name; if (error) { return c.json({ error: error.message }, 500); @@ -383,10 +366,7 @@ tabloRouter.post("/invite", async (c) => { } if (tablo.owner_id !== sender.id) { - return c.json( - { error: "You are not allowed to invite users to this tablo" }, - 400 - ); + return c.json({ error: "You are not allowed to invite users to this tablo" }, 400); } const { error } = await supabase.from("tablo_invites").insert({ @@ -406,9 +386,7 @@ tabloRouter.post("/invite", async (c) => { subject: "Vous avez été invité à un tablo", html: `

Vous avez été invité à un tablo avec ce lien

`, + }/join/${encodeURIComponent(tablo.name)}?token=${encodeURIComponent(token)}">ce lien

`, }); return c.json({ @@ -441,17 +419,15 @@ tabloRouter.post("/join", async (c) => { const { id: invite_id, tablo_id, invited_by } = inviteData; - const { error: tabloAccessError } = await supabase - .from("tablo_access") - .insert({ - tablo_id, - user_id: joiner.id, - // ** IMPORTANT ** - is_admin: false, - // ------------- - is_active: true, - granted_by: invited_by, - }); + const { error: tabloAccessError } = await supabase.from("tablo_access").insert({ + tablo_id, + user_id: joiner.id, + // ** IMPORTANT ** + is_admin: false, + // ------------- + is_active: true, + granted_by: invited_by, + }); if (tabloAccessError) { console.error("tabloAccessError", tabloAccessError); diff --git a/api/src/tablo_data.ts b/api/src/tablo_data.ts index 33098bb..c3b3aab 100644 --- a/api/src/tablo_data.ts +++ b/api/src/tablo_data.ts @@ -1,8 +1,8 @@ -import { Hono, type Context, type Next } from "hono"; -import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js"; -import type { SupabaseClient, User } from "@supabase/supabase-js"; import type { S3Client } from "@aws-sdk/client-s3"; +import type { SupabaseClient, User } from "@supabase/supabase-js"; +import { type Context, Hono, type Next } from "hono"; import { getTabloFileNames, isTabloAdmin, isTabloMember } from "./helpers.js"; +import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js"; export const tabloDataRouter = new Hono<{ Variables: { diff --git a/api/src/tasks.ts b/api/src/tasks.ts index c15105f..4aa3e87 100644 --- a/api/src/tasks.ts +++ b/api/src/tasks.ts @@ -1,8 +1,8 @@ -import { config } from "./config.js"; -import { Hono } from "hono"; import { S3Client } from "@aws-sdk/client-s3"; -import { writeCalendarFileToR2 } from "./helpers.js"; import type { SupabaseClient } from "@supabase/supabase-js"; +import { Hono } from "hono"; +import { config } from "./config.js"; +import { writeCalendarFileToR2 } from "./helpers.js"; export const taskRouter = new Hono<{ Variables: { supabase: SupabaseClient }; @@ -10,7 +10,7 @@ export const taskRouter = new Hono<{ taskRouter.post("/sync-calendars", async (c) => { const supabase = c.get("supabase"); - if (c.req.header("Authorization") !== "Basic " + config.SYNC_CALS_SECRET) { + if (c.req.header("Authorization") !== `Basic ${config.SYNC_CALS_SECRET}`) { return c.json({ error: "Unauthorized" }, 401); } @@ -34,7 +34,7 @@ taskRouter.post("/sync-calendars", async (c) => { token: string; tablo_id: string; tablos: { name: string }; - }, + } ]; calendarSubscriptionsData.forEach(async (subscription) => { diff --git a/api/src/transporter.ts b/api/src/transporter.ts index b4cf283..87357cf 100644 --- a/api/src/transporter.ts +++ b/api/src/transporter.ts @@ -1,6 +1,7 @@ -import nodemailer from "nodemailer"; import { google } from "googleapis"; +import nodemailer from "nodemailer"; import { config } from "./config.js"; + const OAuth2 = google.auth.OAuth2; export const createTransporter = async () => { diff --git a/api/src/user.ts b/api/src/user.ts index e0fbd53..0f01312 100644 --- a/api/src/user.ts +++ b/api/src/user.ts @@ -1,9 +1,9 @@ -import { Hono } from "hono"; -import { authMiddleware, streamChatMiddleware } from "./middleware.js"; import type { SupabaseClient, User } from "@supabase/supabase-js"; -import { StreamChat } from "stream-chat"; +import { Hono } from "hono"; import type { Transporter } from "nodemailer"; +import { StreamChat } from "stream-chat"; import type { Tables } from "./database.types.ts"; +import { authMiddleware, streamChatMiddleware } from "./middleware.js"; import { transporter } from "./transporter.js"; export const userRouter = new Hono<{ @@ -22,7 +22,11 @@ userRouter.post("/sign-up-to-stream", async (c) => { const { id } = c.get("user"); const supabase = c.get("supabase"); - const { data } = await supabase.from("profiles").select("*").eq("id", id).single(); + const { data } = await supabase + .from("profiles") + .select("*") + .eq("id", id) + .single(); const user = data as Tables<"profiles">; @@ -43,7 +47,11 @@ userRouter.get("/me", async (c) => { const supabase = c.get("supabase"); const streamServerClient = c.get("streamServerClient"); - const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single(); + const { data, error } = await supabase + .from("profiles") + .select("*") + .eq("id", user.id) + .single(); const userData = data as Tables<"profiles">; @@ -131,7 +139,6 @@ L'équipe XTablo`, `, }; await transporter.sendMail(mailOptions); - console.log(`Sending welcome email to temporary user: ${profile.email}`); } } catch (error) { console.error("Failed to send welcome email:", error); diff --git a/api/tsconfig.json b/api/tsconfig.json index 674e65d..5963079 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -1,14 +1,15 @@ { "compilerOptions": { - "target": "ESNext", - "module": "NodeNext", - "strict": true, - "verbatimModuleSyntax": true, + "esModuleInterop": true, "skipLibCheck": true, - "types": ["node"], - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx", - "outDir": "./dist" + "target": "es2022", + "module": "NodeNext", + "outDir": "./dist", + "allowJs": true, + "moduleDetection": "force", + "isolatedModules": true, + "verbatimModuleSyntax": true, + "types": ["node"] }, - "exclude": ["node_modules"] + "exclude": ["node_modules", "src/__tests__", "dist"] } diff --git a/static-analysis.datadog.yml b/static-analysis.datadog.yml new file mode 100644 index 0000000..10d15df --- /dev/null +++ b/static-analysis.datadog.yml @@ -0,0 +1,29 @@ +schema-version: v1 +rulesets: + - docker-best-practices + - go-best-practices + - go-security + - javascript-best-practices + - javascript-browser-security + - javascript-code-style + - javascript-common-security + - javascript-express + - javascript-inclusive + - javascript-node-security + - jsx-react + - python-best-practices + - python-code-style + - python-django + - python-flask + - python-inclusive + - python-pandas + - python-security + - tsx-react + - typescript-best-practices + - typescript-browser-security + - typescript-code-style + - typescript-common-security + - typescript-express + - typescript-inclusive + - typescript-node-security + - github-actions diff --git a/xtablo-expo/components/ExternalLink.tsx b/xtablo-expo/components/ExternalLink.tsx index f521bf2..e4a6206 100644 --- a/xtablo-expo/components/ExternalLink.tsx +++ b/xtablo-expo/components/ExternalLink.tsx @@ -1,4 +1,4 @@ -import { Link } from "expo-router"; +import { ExternalPathString, Link, RelativePathString } from "expo-router"; import { openBrowserAsync } from "expo-web-browser"; import { type ComponentProps } from "react"; import { Platform } from "react-native"; @@ -9,8 +9,9 @@ export function ExternalLink({ href, ...rest }: Props) { return ( { if (Platform.OS !== "web") { // Prevent the default behavior of linking to the default browser on native.