Add a lot of tests

This commit is contained in:
Arthur Belleville 2025-10-11 12:32:52 +02:00
parent ab5e233d22
commit b01666ff22
No known key found for this signature in database
24 changed files with 4012 additions and 129 deletions

View file

@ -6,7 +6,10 @@
"build": "tsc",
"start": "node dist/index.js",
"test": "mocha",
"test:watch": "mocha --watch"
"test:watch": "mocha --watch",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write ."
},
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",

222
api/src/__tests__/README.md Normal file
View 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

View 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"]);
});
});
});

View 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;
});
});
});

View 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" });
});
});
});

View file

@ -1,14 +1,14 @@
import { describe, it, beforeEach } from "mocha";
import { expect } from "chai";
import {
generateTimeSlots,
getDayOfWeek,
getDateString,
type WeeklyAvailability,
type Exception,
type EventTypeConfig,
} from "../slots.js";
import { beforeEach, describe, it } from "mocha";
import type { Tables } from "../database.types.js";
import {
type EventTypeConfig,
type Exception,
generateTimeSlots,
getDateString,
getDayOfWeek,
type WeeklyAvailability,
} from "../slots.js";
// Mock the current date for consistent testing

File diff suppressed because it is too large Load diff

View 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" });
});
});
});

View 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" });
});
});
});

View 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;
};
}

View 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" });
});
});
});

View file

@ -1,4 +1,3 @@
import type { EventAndTablo } from "./types.ts";
import {
GetObjectCommand,
ListObjectsCommand,
@ -7,6 +6,7 @@ import {
S3Client,
} from "@aws-sdk/client-s3";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { EventAndTablo } from "./types.ts";
export const generateICSFromEvents = (
events: EventAndTablo[],
@ -15,7 +15,7 @@ export const generateICSFromEvents = (
const formatDate = (date: string, time: string) => {
// Combine date (YYYY-MM-DD) and time (HH:MM:SS) into ISO format then convert to UTC
const dateTime = new Date(`${date}T${time}`);
return dateTime.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
return `${dateTime.toISOString().replace(/[-:]/g, "").split(".")[0]}Z`;
};
const escapeICSText = (text: string) => {
@ -56,19 +56,23 @@ export const generateICSFromEvents = (
`DTSTART:${startDateTime}`,
`DTEND:${endDateTime}`,
`SUMMARY:${escapeICSText(event.title)}`,
`DESCRIPTION:${escapeICSText(`Tablo: ${event.tablo_name}\n${event.description || ""}`)}`,
`DESCRIPTION:${escapeICSText(
`Tablo: ${event.tablo_name}\n${event.description || ""}`
)}`,
event.tablo_name ? `CATEGORIES:${escapeICSText(event.tablo_name)}` : "",
`CREATED:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`,
`LAST-MODIFIED:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`,
`LAST-MODIFIED:${
new Date().toISOString().replace(/[-:]/g, "").split(".")[0]
}Z`,
"STATUS:CONFIRMED",
"TRANSP:OPAQUE",
"END:VEVENT",
].filter((line) => line !== ""); // Remove empty lines
icsContent += "\r\n" + eventLines.join("\r\n");
icsContent += `\r\n${eventLines.join("\r\n")}`;
});
icsContent += "\r\n" + "END:VCALENDAR";
icsContent += "\r\nEND:VCALENDAR";
return icsContent;
};
@ -110,7 +114,10 @@ export const writeCalendarFileToR2 = async (
);
};
export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) => {
export const getTabloFileNames = async (
s3_client: S3Client,
tabloId: string
) => {
const bucketName = "tablo-data";
const { Contents } = await s3_client.send(
@ -125,7 +132,11 @@ export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) =>
);
};
export const isTabloMember = async (supabase: SupabaseClient, tabloId: string, userId: string) => {
export const isTabloMember = async (
supabase: SupabaseClient,
tabloId: string,
userId: string
) => {
const { data: tabloAccess, error: isMemberError } = await supabase
.from("tablo_access")
.select("*")
@ -140,7 +151,11 @@ export const isTabloMember = async (supabase: SupabaseClient, tabloId: string, u
return tabloAccess?.length > 0;
};
export const isTabloAdmin = async (supabase: SupabaseClient, tabloId: string, userId: string) => {
export const isTabloAdmin = async (
supabase: SupabaseClient,
tabloId: string,
userId: string
) => {
const { data: tabloAccess, error: isAdminError } = await supabase
.from("tablo_access")
.select("*")

View file

@ -1,14 +1,13 @@
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { logger } from "hono/logger";
import { mainRouter } from "./routers.js";
import { cors } from "hono/cors";
import { config } from "./config.js";
import { run } from "graphile-worker";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import path from "path";
import { fileURLToPath } from "url";
import { config } from "./config.js";
import { publicRouter } from "./public.js";
import { mainRouter } from "./routers.js";
const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file
const __dirname = path.dirname(__filename); // get the name of the directory

View file

@ -1,15 +1,15 @@
import { Hono } from "hono";
import type { SupabaseClient } from "@supabase/supabase-js";
import { supabaseMiddleware } from "./middleware.js";
import { Hono } from "hono";
import type { Database, Tables } from "./database.types.js";
import { supabaseMiddleware } from "./middleware.js";
import {
type EventTypeConfig,
type Exception,
generateTimeSlots,
getDateString,
getDayOfWeek,
type TimeSlot,
type WeeklyAvailability,
type Exception,
type EventTypeConfig,
} from "./slots.js";
// Helper function to get current time in CET

View file

@ -1,9 +1,9 @@
import { Hono } from "hono";
import { userRouter } from "./user.js";
import { supabaseMiddleware } from "./middleware.js";
import { tabloRouter } from "./tablo.js";
import { taskRouter } from "./tasks.js";
import { tabloDataRouter } from "./tablo_data.js";
import { taskRouter } from "./tasks.js";
import { userRouter } from "./user.js";
export const mainRouter = new Hono<{
Bindings: {

View file

@ -2,9 +2,6 @@ import type { Tables } from "./database.types.js";
// Helper function to convert UTC date to CET
function convertToCET(utcDate: Date): Date {
// Create a new date object to avoid mutating the original
const cetDate = new Date(utcDate);
// Use Intl.DateTimeFormat to get the correct CET/CEST offset
const formatter = new Intl.DateTimeFormat("en", {
timeZone: "Europe/Paris",
@ -19,7 +16,8 @@ function convertToCET(utcDate: Date): Date {
const parts = formatter.formatToParts(utcDate);
const year = parseInt(parts.find((p) => p.type === "year")?.value || "0");
const month = parseInt(parts.find((p) => p.type === "month")?.value || "0") - 1; // Month is 0-indexed
const month =
parseInt(parts.find((p) => p.type === "month")?.value || "0") - 1; // Month is 0-indexed
const day = parseInt(parts.find((p) => p.type === "day")?.value || "0");
const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0");
const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0");
@ -83,7 +81,9 @@ function parseTime(timeStr: string): { hours: number; minutes: number } {
}
function formatTime(hours: number, minutes: number): string {
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
return `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}`;
}
function addMinutes(timeStr: string, minutesToAdd: number): string {
@ -102,15 +102,13 @@ function addMinutes(timeStr: string, minutesToAdd: number): string {
return formatTime(newHours, newMinutes);
}
function isTimeInRange(time: string, range: TimeRange): boolean {
return time >= range.start && time <= range.end;
}
function mergeOverlappingTimeRanges(ranges: TimeRange[]): TimeRange[] {
if (ranges.length <= 1) return ranges;
// Sort ranges by start time
const sortedRanges = [...ranges].sort((a, b) => a.start.localeCompare(b.start));
const sortedRanges = [...ranges].sort((a, b) =>
a.start.localeCompare(b.start)
);
const merged: TimeRange[] = [sortedRanges[0]];
for (let i = 1; i < sortedRanges.length; i++) {
@ -120,7 +118,8 @@ function mergeOverlappingTimeRanges(ranges: TimeRange[]): TimeRange[] {
// Check if current range overlaps with the last merged range
if (current.start <= lastMerged.end) {
// Merge by extending the end time if current range extends further
lastMerged.end = current.end > lastMerged.end ? current.end : lastMerged.end;
lastMerged.end =
current.end > lastMerged.end ? current.end : lastMerged.end;
} else {
// No overlap, add current range to merged array
merged.push(current);
@ -210,7 +209,10 @@ export function generateTimeSlots(
}
// Check minimum advance booking
const minAdvanceBooking = getMinAdvanceBookingDate(eventTypeConfig, currentTime);
const minAdvanceBooking = getMinAdvanceBookingDate(
eventTypeConfig,
currentTime
);
// Generate slots for each time range
for (const range of timeRanges) {
@ -221,13 +223,17 @@ export function generateTimeSlots(
const endMinutes = endTime.hours * 60 + endTime.minutes;
while (currentMinutes + eventTypeConfig.duration <= endMinutes) {
const slotTime = formatTime(Math.floor(currentMinutes / 60), currentMinutes % 60);
const slotTime = formatTime(
Math.floor(currentMinutes / 60),
currentMinutes % 60
);
// Check if slot is in the future (considering minimum advance booking)
// Compare dates first, then times if on the same date
const isInFuture =
dateStr > minAdvanceBooking.date ||
(dateStr === minAdvanceBooking.date && slotTime >= minAdvanceBooking.time);
(dateStr === minAdvanceBooking.date &&
slotTime >= minAdvanceBooking.time);
slots.push({
date: dateStr,
@ -253,7 +259,8 @@ export function generateTimeSlots(
if (event.start_date !== dateStr || event.deleted_at) return false;
const eventStart = event.start_time;
const eventEnd = event.end_time || addMinutes(eventStart, eventTypeConfig.duration);
const eventEnd =
event.end_time || addMinutes(eventStart, eventTypeConfig.duration);
// Apply buffer time around the existing event
const bufferedEventStart = addMinutes(eventStart, -bufferTime);

View file

@ -1,27 +1,15 @@
import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import { PostgrestError, type SupabaseClient, type User } from "@supabase/supabase-js";
import { Hono } from "hono";
import {
authMiddleware,
r2Middleware,
streamChatMiddleware,
} from "./middleware.js";
import {
PostgrestError,
type SupabaseClient,
type User,
} from "@supabase/supabase-js";
import type { Transporter } from "nodemailer";
import { generateToken } from "./token.js";
import type { StreamChat } from "stream-chat";
import { config } from "./config.js";
import type { Tables } from "./database.types.ts";
import type { StreamChat } from "stream-chat";
import type {
TabloInsert,
EventInsertInTablo,
EventAndTablo,
} from "./types.ts";
import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import { generateICSFromEvents, writeCalendarFileToR2 } from "./helpers.js";
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
import { generateToken } from "./token.js";
import { transporter } from "./transporter.js";
import type { EventAndTablo, EventInsertInTablo, TabloInsert } from "./types.ts";
export const tabloRouter = new Hono<{
Variables: {
@ -178,9 +166,7 @@ tabloRouter.post("/create-and-invite", async (c) => {
const { data: insertedTablo, error } = await supabase
.from("tablos")
.insert({
name: `${invitedUserDataTyped.name || "Invité"} / ${
ownerDataTyped.name || "Propriétaire"
}`,
name: `${invitedUserDataTyped.name || "Invité"} / ${ownerDataTyped.name || "Propriétaire"}`,
color: "bg-blue-500",
status: "todo",
owner_id: ownerId,
@ -198,22 +184,20 @@ tabloRouter.post("/create-and-invite", async (c) => {
}
// Grant access to the current user (invited user) as a non-admin member
const { error: tabloAccessError } = await supabase
.from("tablo_access")
.insert(
{
tablo_id: tabloData.id,
user_id: user.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: ownerId,
}
// {
// onConflict: "tablo_id, user_id",
// }
);
const { error: tabloAccessError } = await supabase.from("tablo_access").insert(
{
tablo_id: tabloData.id,
user_id: user.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: ownerId,
}
// {
// onConflict: "tablo_id, user_id",
// }
);
if (tabloAccessError) {
console.error("tabloAccessError", tabloAccessError);
@ -306,8 +290,7 @@ tabloRouter.patch("/update", async (c) => {
const updatedTablo = update as Tables<"tablos">;
const isUpdatingName =
tablo.name !== undefined && tablo.name !== updatedTablo.name;
const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name;
if (error) {
return c.json({ error: error.message }, 500);
@ -383,10 +366,7 @@ tabloRouter.post("/invite", async (c) => {
}
if (tablo.owner_id !== sender.id) {
return c.json(
{ error: "You are not allowed to invite users to this tablo" },
400
);
return c.json({ error: "You are not allowed to invite users to this tablo" }, 400);
}
const { error } = await supabase.from("tablo_invites").insert({
@ -406,9 +386,7 @@ tabloRouter.post("/invite", async (c) => {
subject: "Vous avez été invité à un tablo",
html: `<p>Vous avez été invité à un tablo avec <a href="${
config.XTABLO_URL
}/join/${encodeURIComponent(tablo.name)}?token=${encodeURIComponent(
token
)}">ce lien</a></p>`,
}/join/${encodeURIComponent(tablo.name)}?token=${encodeURIComponent(token)}">ce lien</a></p>`,
});
return c.json({
@ -441,17 +419,15 @@ tabloRouter.post("/join", async (c) => {
const { id: invite_id, tablo_id, invited_by } = inviteData;
const { error: tabloAccessError } = await supabase
.from("tablo_access")
.insert({
tablo_id,
user_id: joiner.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: invited_by,
});
const { error: tabloAccessError } = await supabase.from("tablo_access").insert({
tablo_id,
user_id: joiner.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: invited_by,
});
if (tabloAccessError) {
console.error("tabloAccessError", tabloAccessError);

View file

@ -1,8 +1,8 @@
import { Hono, type Context, type Next } from "hono";
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { S3Client } from "@aws-sdk/client-s3";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import { type Context, Hono, type Next } from "hono";
import { getTabloFileNames, isTabloAdmin, isTabloMember } from "./helpers.js";
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
export const tabloDataRouter = new Hono<{
Variables: {

View file

@ -1,8 +1,8 @@
import { config } from "./config.js";
import { Hono } from "hono";
import { S3Client } from "@aws-sdk/client-s3";
import { writeCalendarFileToR2 } from "./helpers.js";
import type { SupabaseClient } from "@supabase/supabase-js";
import { Hono } from "hono";
import { config } from "./config.js";
import { writeCalendarFileToR2 } from "./helpers.js";
export const taskRouter = new Hono<{
Variables: { supabase: SupabaseClient };
@ -10,7 +10,7 @@ export const taskRouter = new Hono<{
taskRouter.post("/sync-calendars", async (c) => {
const supabase = c.get("supabase");
if (c.req.header("Authorization") !== "Basic " + config.SYNC_CALS_SECRET) {
if (c.req.header("Authorization") !== `Basic ${config.SYNC_CALS_SECRET}`) {
return c.json({ error: "Unauthorized" }, 401);
}
@ -34,7 +34,7 @@ taskRouter.post("/sync-calendars", async (c) => {
token: string;
tablo_id: string;
tablos: { name: string };
},
}
];
calendarSubscriptionsData.forEach(async (subscription) => {

View file

@ -1,6 +1,7 @@
import nodemailer from "nodemailer";
import { google } from "googleapis";
import nodemailer from "nodemailer";
import { config } from "./config.js";
const OAuth2 = google.auth.OAuth2;
export const createTransporter = async () => {

View file

@ -1,9 +1,9 @@
import { Hono } from "hono";
import { authMiddleware, streamChatMiddleware } from "./middleware.js";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import { StreamChat } from "stream-chat";
import { Hono } from "hono";
import type { Transporter } from "nodemailer";
import { StreamChat } from "stream-chat";
import type { Tables } from "./database.types.ts";
import { authMiddleware, streamChatMiddleware } from "./middleware.js";
import { transporter } from "./transporter.js";
export const userRouter = new Hono<{
@ -22,7 +22,11 @@ userRouter.post("/sign-up-to-stream", async (c) => {
const { id } = c.get("user");
const supabase = c.get("supabase");
const { data } = await supabase.from("profiles").select("*").eq("id", id).single();
const { data } = await supabase
.from("profiles")
.select("*")
.eq("id", id)
.single();
const user = data as Tables<"profiles">;
@ -43,7 +47,11 @@ userRouter.get("/me", async (c) => {
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single();
const { data, error } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single();
const userData = data as Tables<"profiles">;
@ -131,7 +139,6 @@ L'équipe XTablo`,
`,
};
await transporter.sendMail(mailOptions);
console.log(`Sending welcome email to temporary user: ${profile.email}`);
}
} catch (error) {
console.error("Failed to send welcome email:", error);

View file

@ -1,14 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"strict": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"outDir": "./dist"
"target": "es2022",
"module": "NodeNext",
"outDir": "./dist",
"allowJs": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"types": ["node"]
},
"exclude": ["node_modules"]
"exclude": ["node_modules", "src/__tests__", "dist"]
}

View 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

View file

@ -1,4 +1,4 @@
import { Link } from "expo-router";
import { ExternalPathString, Link, RelativePathString } from "expo-router";
import { openBrowserAsync } from "expo-web-browser";
import { type ComponentProps } from "react";
import { Platform } from "react-native";
@ -9,8 +9,9 @@ export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
rel="noreferrer"
{...rest}
href={href}
href={href as RelativePathString | ExternalPathString}
onPress={async (event) => {
if (Platform.OS !== "web") {
// Prevent the default behavior of linking to the default browser on native.