Add a lot of tests
This commit is contained in:
parent
ab5e233d22
commit
b01666ff22
24 changed files with 4012 additions and 129 deletions
|
|
@ -6,7 +6,10 @@
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "mocha",
|
"test": "mocha",
|
||||||
"test:watch": "mocha --watch"
|
"test:watch": "mocha --watch",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"lint:fix": "biome check --write .",
|
||||||
|
"format": "biome format --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
|
|
|
||||||
222
api/src/__tests__/README.md
Normal file
222
api/src/__tests__/README.md
Normal file
|
|
@ -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
|
||||||
426
api/src/__tests__/helpers.test.ts
Normal file
426
api/src/__tests__/helpers.test.ts
Normal file
|
|
@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
179
api/src/__tests__/middleware.test.ts
Normal file
179
api/src/__tests__/middleware.test.ts
Normal file
|
|
@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
509
api/src/__tests__/public.test.ts
Normal file
509
api/src/__tests__/public.test.ts
Normal file
|
|
@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { describe, it, beforeEach } from "mocha";
|
|
||||||
import { expect } from "chai";
|
import { expect } from "chai";
|
||||||
import {
|
import { beforeEach, describe, it } from "mocha";
|
||||||
generateTimeSlots,
|
|
||||||
getDayOfWeek,
|
|
||||||
getDateString,
|
|
||||||
type WeeklyAvailability,
|
|
||||||
type Exception,
|
|
||||||
type EventTypeConfig,
|
|
||||||
} from "../slots.js";
|
|
||||||
import type { Tables } from "../database.types.js";
|
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
|
// Mock the current date for consistent testing
|
||||||
|
|
||||||
|
|
|
||||||
1287
api/src/__tests__/tablo.test.ts
Normal file
1287
api/src/__tests__/tablo.test.ts
Normal file
File diff suppressed because it is too large
Load diff
497
api/src/__tests__/tablo_data.test.ts
Normal file
497
api/src/__tests__/tablo_data.test.ts
Normal file
|
|
@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
184
api/src/__tests__/tasks.test.ts
Normal file
184
api/src/__tests__/tasks.test.ts
Normal file
|
|
@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
203
api/src/__tests__/test-utils.ts
Normal file
203
api/src/__tests__/test-utils.ts
Normal file
|
|
@ -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<StreamChat["channel"]>;
|
||||||
|
} {
|
||||||
|
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<StreamChat["channel"]>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<string, unknown> = {}) {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
337
api/src/__tests__/user.test.ts
Normal file
337
api/src/__tests__/user.test.ts
Normal file
|
|
@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import type { EventAndTablo } from "./types.ts";
|
|
||||||
import {
|
import {
|
||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
ListObjectsCommand,
|
ListObjectsCommand,
|
||||||
|
|
@ -7,6 +6,7 @@ import {
|
||||||
S3Client,
|
S3Client,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import type { EventAndTablo } from "./types.ts";
|
||||||
|
|
||||||
export const generateICSFromEvents = (
|
export const generateICSFromEvents = (
|
||||||
events: EventAndTablo[],
|
events: EventAndTablo[],
|
||||||
|
|
@ -15,7 +15,7 @@ export const generateICSFromEvents = (
|
||||||
const formatDate = (date: string, time: string) => {
|
const formatDate = (date: string, time: string) => {
|
||||||
// Combine date (YYYY-MM-DD) and time (HH:MM:SS) into ISO format then convert to UTC
|
// Combine date (YYYY-MM-DD) and time (HH:MM:SS) into ISO format then convert to UTC
|
||||||
const dateTime = new Date(`${date}T${time}`);
|
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) => {
|
const escapeICSText = (text: string) => {
|
||||||
|
|
@ -56,19 +56,23 @@ export const generateICSFromEvents = (
|
||||||
`DTSTART:${startDateTime}`,
|
`DTSTART:${startDateTime}`,
|
||||||
`DTEND:${endDateTime}`,
|
`DTEND:${endDateTime}`,
|
||||||
`SUMMARY:${escapeICSText(event.title)}`,
|
`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)}` : "",
|
event.tablo_name ? `CATEGORIES:${escapeICSText(event.tablo_name)}` : "",
|
||||||
`CREATED:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`,
|
`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",
|
"STATUS:CONFIRMED",
|
||||||
"TRANSP:OPAQUE",
|
"TRANSP:OPAQUE",
|
||||||
"END:VEVENT",
|
"END:VEVENT",
|
||||||
].filter((line) => line !== ""); // Remove empty lines
|
].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;
|
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 bucketName = "tablo-data";
|
||||||
|
|
||||||
const { Contents } = await s3_client.send(
|
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
|
const { data: tabloAccess, error: isMemberError } = await supabase
|
||||||
.from("tablo_access")
|
.from("tablo_access")
|
||||||
.select("*")
|
.select("*")
|
||||||
|
|
@ -140,7 +151,11 @@ export const isTabloMember = async (supabase: SupabaseClient, tabloId: string, u
|
||||||
return tabloAccess?.length > 0;
|
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
|
const { data: tabloAccess, error: isAdminError } = await supabase
|
||||||
.from("tablo_access")
|
.from("tablo_access")
|
||||||
.select("*")
|
.select("*")
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import { Hono } from "hono";
|
|
||||||
import { serve } from "@hono/node-server";
|
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 { run } from "graphile-worker";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
import { logger } from "hono/logger";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
import { config } from "./config.js";
|
||||||
import { publicRouter } from "./public.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 __filename = fileURLToPath(import.meta.url); // get the resolved path to the file
|
||||||
const __dirname = path.dirname(__filename); // get the name of the directory
|
const __dirname = path.dirname(__filename); // get the name of the directory
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { Hono } from "hono";
|
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
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 type { Database, Tables } from "./database.types.js";
|
||||||
|
import { supabaseMiddleware } from "./middleware.js";
|
||||||
import {
|
import {
|
||||||
|
type EventTypeConfig,
|
||||||
|
type Exception,
|
||||||
generateTimeSlots,
|
generateTimeSlots,
|
||||||
getDateString,
|
getDateString,
|
||||||
getDayOfWeek,
|
getDayOfWeek,
|
||||||
type TimeSlot,
|
type TimeSlot,
|
||||||
type WeeklyAvailability,
|
type WeeklyAvailability,
|
||||||
type Exception,
|
|
||||||
type EventTypeConfig,
|
|
||||||
} from "./slots.js";
|
} from "./slots.js";
|
||||||
|
|
||||||
// Helper function to get current time in CET
|
// Helper function to get current time in CET
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { userRouter } from "./user.js";
|
|
||||||
import { supabaseMiddleware } from "./middleware.js";
|
import { supabaseMiddleware } from "./middleware.js";
|
||||||
import { tabloRouter } from "./tablo.js";
|
import { tabloRouter } from "./tablo.js";
|
||||||
import { taskRouter } from "./tasks.js";
|
|
||||||
import { tabloDataRouter } from "./tablo_data.js";
|
import { tabloDataRouter } from "./tablo_data.js";
|
||||||
|
import { taskRouter } from "./tasks.js";
|
||||||
|
import { userRouter } from "./user.js";
|
||||||
|
|
||||||
export const mainRouter = new Hono<{
|
export const mainRouter = new Hono<{
|
||||||
Bindings: {
|
Bindings: {
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@ import type { Tables } from "./database.types.js";
|
||||||
|
|
||||||
// Helper function to convert UTC date to CET
|
// Helper function to convert UTC date to CET
|
||||||
function convertToCET(utcDate: Date): Date {
|
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
|
// Use Intl.DateTimeFormat to get the correct CET/CEST offset
|
||||||
const formatter = new Intl.DateTimeFormat("en", {
|
const formatter = new Intl.DateTimeFormat("en", {
|
||||||
timeZone: "Europe/Paris",
|
timeZone: "Europe/Paris",
|
||||||
|
|
@ -19,7 +16,8 @@ function convertToCET(utcDate: Date): Date {
|
||||||
|
|
||||||
const parts = formatter.formatToParts(utcDate);
|
const parts = formatter.formatToParts(utcDate);
|
||||||
const year = parseInt(parts.find((p) => p.type === "year")?.value || "0");
|
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 day = parseInt(parts.find((p) => p.type === "day")?.value || "0");
|
||||||
const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0");
|
const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0");
|
||||||
const minute = parseInt(parts.find((p) => p.type === "minute")?.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 {
|
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 {
|
function addMinutes(timeStr: string, minutesToAdd: number): string {
|
||||||
|
|
@ -102,15 +102,13 @@ function addMinutes(timeStr: string, minutesToAdd: number): string {
|
||||||
return formatTime(newHours, newMinutes);
|
return formatTime(newHours, newMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTimeInRange(time: string, range: TimeRange): boolean {
|
|
||||||
return time >= range.start && time <= range.end;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeOverlappingTimeRanges(ranges: TimeRange[]): TimeRange[] {
|
function mergeOverlappingTimeRanges(ranges: TimeRange[]): TimeRange[] {
|
||||||
if (ranges.length <= 1) return ranges;
|
if (ranges.length <= 1) return ranges;
|
||||||
|
|
||||||
// Sort ranges by start time
|
// 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]];
|
const merged: TimeRange[] = [sortedRanges[0]];
|
||||||
|
|
||||||
for (let i = 1; i < sortedRanges.length; i++) {
|
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
|
// Check if current range overlaps with the last merged range
|
||||||
if (current.start <= lastMerged.end) {
|
if (current.start <= lastMerged.end) {
|
||||||
// Merge by extending the end time if current range extends further
|
// 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 {
|
} else {
|
||||||
// No overlap, add current range to merged array
|
// No overlap, add current range to merged array
|
||||||
merged.push(current);
|
merged.push(current);
|
||||||
|
|
@ -210,7 +209,10 @@ export function generateTimeSlots(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check minimum advance booking
|
// Check minimum advance booking
|
||||||
const minAdvanceBooking = getMinAdvanceBookingDate(eventTypeConfig, currentTime);
|
const minAdvanceBooking = getMinAdvanceBookingDate(
|
||||||
|
eventTypeConfig,
|
||||||
|
currentTime
|
||||||
|
);
|
||||||
|
|
||||||
// Generate slots for each time range
|
// Generate slots for each time range
|
||||||
for (const range of timeRanges) {
|
for (const range of timeRanges) {
|
||||||
|
|
@ -221,13 +223,17 @@ export function generateTimeSlots(
|
||||||
const endMinutes = endTime.hours * 60 + endTime.minutes;
|
const endMinutes = endTime.hours * 60 + endTime.minutes;
|
||||||
|
|
||||||
while (currentMinutes + eventTypeConfig.duration <= endMinutes) {
|
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)
|
// Check if slot is in the future (considering minimum advance booking)
|
||||||
// Compare dates first, then times if on the same date
|
// Compare dates first, then times if on the same date
|
||||||
const isInFuture =
|
const isInFuture =
|
||||||
dateStr > minAdvanceBooking.date ||
|
dateStr > minAdvanceBooking.date ||
|
||||||
(dateStr === minAdvanceBooking.date && slotTime >= minAdvanceBooking.time);
|
(dateStr === minAdvanceBooking.date &&
|
||||||
|
slotTime >= minAdvanceBooking.time);
|
||||||
|
|
||||||
slots.push({
|
slots.push({
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
|
|
@ -253,7 +259,8 @@ export function generateTimeSlots(
|
||||||
if (event.start_date !== dateStr || event.deleted_at) return false;
|
if (event.start_date !== dateStr || event.deleted_at) return false;
|
||||||
|
|
||||||
const eventStart = event.start_time;
|
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
|
// Apply buffer time around the existing event
|
||||||
const bufferedEventStart = addMinutes(eventStart, -bufferTime);
|
const bufferedEventStart = addMinutes(eventStart, -bufferTime);
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 type { Transporter } from "nodemailer";
|
||||||
import { generateToken } from "./token.js";
|
import type { StreamChat } from "stream-chat";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import type { Tables } from "./database.types.ts";
|
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 { generateICSFromEvents, writeCalendarFileToR2 } from "./helpers.js";
|
||||||
|
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
|
||||||
|
import { generateToken } from "./token.js";
|
||||||
import { transporter } from "./transporter.js";
|
import { transporter } from "./transporter.js";
|
||||||
|
import type { EventAndTablo, EventInsertInTablo, TabloInsert } from "./types.ts";
|
||||||
|
|
||||||
export const tabloRouter = new Hono<{
|
export const tabloRouter = new Hono<{
|
||||||
Variables: {
|
Variables: {
|
||||||
|
|
@ -178,9 +166,7 @@ tabloRouter.post("/create-and-invite", async (c) => {
|
||||||
const { data: insertedTablo, error } = await supabase
|
const { data: insertedTablo, error } = await supabase
|
||||||
.from("tablos")
|
.from("tablos")
|
||||||
.insert({
|
.insert({
|
||||||
name: `${invitedUserDataTyped.name || "Invité"} / ${
|
name: `${invitedUserDataTyped.name || "Invité"} / ${ownerDataTyped.name || "Propriétaire"}`,
|
||||||
ownerDataTyped.name || "Propriétaire"
|
|
||||||
}`,
|
|
||||||
color: "bg-blue-500",
|
color: "bg-blue-500",
|
||||||
status: "todo",
|
status: "todo",
|
||||||
owner_id: ownerId,
|
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
|
// Grant access to the current user (invited user) as a non-admin member
|
||||||
const { error: tabloAccessError } = await supabase
|
const { error: tabloAccessError } = await supabase.from("tablo_access").insert(
|
||||||
.from("tablo_access")
|
{
|
||||||
.insert(
|
tablo_id: tabloData.id,
|
||||||
{
|
user_id: user.id,
|
||||||
tablo_id: tabloData.id,
|
// ** IMPORTANT **
|
||||||
user_id: user.id,
|
is_admin: false,
|
||||||
// ** IMPORTANT **
|
// -------------
|
||||||
is_admin: false,
|
is_active: true,
|
||||||
// -------------
|
granted_by: ownerId,
|
||||||
is_active: true,
|
}
|
||||||
granted_by: ownerId,
|
// {
|
||||||
}
|
// onConflict: "tablo_id, user_id",
|
||||||
// {
|
// }
|
||||||
// onConflict: "tablo_id, user_id",
|
);
|
||||||
// }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tabloAccessError) {
|
if (tabloAccessError) {
|
||||||
console.error("tabloAccessError", tabloAccessError);
|
console.error("tabloAccessError", tabloAccessError);
|
||||||
|
|
@ -306,8 +290,7 @@ tabloRouter.patch("/update", async (c) => {
|
||||||
|
|
||||||
const updatedTablo = update as Tables<"tablos">;
|
const updatedTablo = update as Tables<"tablos">;
|
||||||
|
|
||||||
const isUpdatingName =
|
const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name;
|
||||||
tablo.name !== undefined && tablo.name !== updatedTablo.name;
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return c.json({ error: error.message }, 500);
|
return c.json({ error: error.message }, 500);
|
||||||
|
|
@ -383,10 +366,7 @@ tabloRouter.post("/invite", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tablo.owner_id !== sender.id) {
|
if (tablo.owner_id !== sender.id) {
|
||||||
return c.json(
|
return c.json({ error: "You are not allowed to invite users to this tablo" }, 400);
|
||||||
{ error: "You are not allowed to invite users to this tablo" },
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase.from("tablo_invites").insert({
|
const { error } = await supabase.from("tablo_invites").insert({
|
||||||
|
|
@ -406,9 +386,7 @@ tabloRouter.post("/invite", async (c) => {
|
||||||
subject: "Vous avez été invité à un tablo",
|
subject: "Vous avez été invité à un tablo",
|
||||||
html: `<p>Vous avez été invité à un tablo avec <a href="${
|
html: `<p>Vous avez été invité à un tablo avec <a href="${
|
||||||
config.XTABLO_URL
|
config.XTABLO_URL
|
||||||
}/join/${encodeURIComponent(tablo.name)}?token=${encodeURIComponent(
|
}/join/${encodeURIComponent(tablo.name)}?token=${encodeURIComponent(token)}">ce lien</a></p>`,
|
||||||
token
|
|
||||||
)}">ce lien</a></p>`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
|
|
@ -441,17 +419,15 @@ tabloRouter.post("/join", async (c) => {
|
||||||
|
|
||||||
const { id: invite_id, tablo_id, invited_by } = inviteData;
|
const { id: invite_id, tablo_id, invited_by } = inviteData;
|
||||||
|
|
||||||
const { error: tabloAccessError } = await supabase
|
const { error: tabloAccessError } = await supabase.from("tablo_access").insert({
|
||||||
.from("tablo_access")
|
tablo_id,
|
||||||
.insert({
|
user_id: joiner.id,
|
||||||
tablo_id,
|
// ** IMPORTANT **
|
||||||
user_id: joiner.id,
|
is_admin: false,
|
||||||
// ** IMPORTANT **
|
// -------------
|
||||||
is_admin: false,
|
is_active: true,
|
||||||
// -------------
|
granted_by: invited_by,
|
||||||
is_active: true,
|
});
|
||||||
granted_by: invited_by,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tabloAccessError) {
|
if (tabloAccessError) {
|
||||||
console.error("tabloAccessError", tabloAccessError);
|
console.error("tabloAccessError", tabloAccessError);
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 { getTabloFileNames, isTabloAdmin, isTabloMember } from "./helpers.js";
|
||||||
|
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
|
||||||
|
|
||||||
export const tabloDataRouter = new Hono<{
|
export const tabloDataRouter = new Hono<{
|
||||||
Variables: {
|
Variables: {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { config } from "./config.js";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
import { S3Client } from "@aws-sdk/client-s3";
|
import { S3Client } from "@aws-sdk/client-s3";
|
||||||
import { writeCalendarFileToR2 } from "./helpers.js";
|
|
||||||
import type { SupabaseClient } from "@supabase/supabase-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<{
|
export const taskRouter = new Hono<{
|
||||||
Variables: { supabase: SupabaseClient };
|
Variables: { supabase: SupabaseClient };
|
||||||
|
|
@ -10,7 +10,7 @@ export const taskRouter = new Hono<{
|
||||||
|
|
||||||
taskRouter.post("/sync-calendars", async (c) => {
|
taskRouter.post("/sync-calendars", async (c) => {
|
||||||
const supabase = c.get("supabase");
|
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);
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,7 +34,7 @@ taskRouter.post("/sync-calendars", async (c) => {
|
||||||
token: string;
|
token: string;
|
||||||
tablo_id: string;
|
tablo_id: string;
|
||||||
tablos: { name: string };
|
tablos: { name: string };
|
||||||
},
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
calendarSubscriptionsData.forEach(async (subscription) => {
|
calendarSubscriptionsData.forEach(async (subscription) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import nodemailer from "nodemailer";
|
|
||||||
import { google } from "googleapis";
|
import { google } from "googleapis";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
|
|
||||||
const OAuth2 = google.auth.OAuth2;
|
const OAuth2 = google.auth.OAuth2;
|
||||||
|
|
||||||
export const createTransporter = async () => {
|
export const createTransporter = async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Hono } from "hono";
|
|
||||||
import { authMiddleware, streamChatMiddleware } from "./middleware.js";
|
|
||||||
import type { SupabaseClient, User } from "@supabase/supabase-js";
|
import type { SupabaseClient, User } from "@supabase/supabase-js";
|
||||||
import { StreamChat } from "stream-chat";
|
import { Hono } from "hono";
|
||||||
import type { Transporter } from "nodemailer";
|
import type { Transporter } from "nodemailer";
|
||||||
|
import { StreamChat } from "stream-chat";
|
||||||
import type { Tables } from "./database.types.ts";
|
import type { Tables } from "./database.types.ts";
|
||||||
|
import { authMiddleware, streamChatMiddleware } from "./middleware.js";
|
||||||
import { transporter } from "./transporter.js";
|
import { transporter } from "./transporter.js";
|
||||||
|
|
||||||
export const userRouter = new Hono<{
|
export const userRouter = new Hono<{
|
||||||
|
|
@ -22,7 +22,11 @@ userRouter.post("/sign-up-to-stream", async (c) => {
|
||||||
const { id } = c.get("user");
|
const { id } = c.get("user");
|
||||||
const supabase = c.get("supabase");
|
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">;
|
const user = data as Tables<"profiles">;
|
||||||
|
|
||||||
|
|
@ -43,7 +47,11 @@ userRouter.get("/me", async (c) => {
|
||||||
const supabase = c.get("supabase");
|
const supabase = c.get("supabase");
|
||||||
const streamServerClient = c.get("streamServerClient");
|
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">;
|
const userData = data as Tables<"profiles">;
|
||||||
|
|
||||||
|
|
@ -131,7 +139,6 @@ L'équipe XTablo`,
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
await transporter.sendMail(mailOptions);
|
await transporter.sendMail(mailOptions);
|
||||||
console.log(`Sending welcome email to temporary user: ${profile.email}`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send welcome email:", error);
|
console.error("Failed to send welcome email:", error);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"esModuleInterop": true,
|
||||||
"module": "NodeNext",
|
|
||||||
"strict": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": ["node"],
|
"target": "es2022",
|
||||||
"jsx": "react-jsx",
|
"module": "NodeNext",
|
||||||
"jsxImportSource": "hono/jsx",
|
"outDir": "./dist",
|
||||||
"outDir": "./dist"
|
"allowJs": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "src/__tests__", "dist"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
static-analysis.datadog.yml
Normal file
29
static-analysis.datadog.yml
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Link } from "expo-router";
|
import { ExternalPathString, Link, RelativePathString } from "expo-router";
|
||||||
import { openBrowserAsync } from "expo-web-browser";
|
import { openBrowserAsync } from "expo-web-browser";
|
||||||
import { type ComponentProps } from "react";
|
import { type ComponentProps } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
@ -9,8 +9,9 @@ export function ExternalLink({ href, ...rest }: Props) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
{...rest}
|
{...rest}
|
||||||
href={href}
|
href={href as RelativePathString | ExternalPathString}
|
||||||
onPress={async (event) => {
|
onPress={async (event) => {
|
||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== "web") {
|
||||||
// Prevent the default behavior of linking to the default browser on native.
|
// Prevent the default behavior of linking to the default browser on native.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue