This commit is contained in:
Arthur Belleville 2025-10-12 11:31:41 +02:00
parent c12326fe5e
commit 75b1bab640
No known key found for this signature in database
13 changed files with 82 additions and 3877 deletions

View file

@ -1,222 +0,0 @@
# 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

@ -1,426 +0,0 @@
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

@ -1,179 +0,0 @@
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

@ -1,509 +0,0 @@
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" });
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,497 +0,0 @@
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

@ -1,184 +0,0 @@
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

@ -1,203 +0,0 @@
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

@ -1,337 +0,0 @@
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,15 +1,27 @@
import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import { PostgrestError, type SupabaseClient, type User } from "@supabase/supabase-js";
import {
PostgrestError,
type SupabaseClient,
type User,
} from "@supabase/supabase-js";
import { Hono } from "hono";
import type { Transporter } from "nodemailer";
import type { StreamChat } from "stream-chat";
import { config } from "./config.js";
import type { Tables } from "./database.types.ts";
import { generateICSFromEvents, writeCalendarFileToR2 } from "./helpers.js";
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.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";
import type {
EventAndTablo,
EventInsertInTablo,
TabloInsert,
} from "./types.ts";
export const tabloRouter = new Hono<{
Variables: {
@ -166,7 +178,9 @@ 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,
@ -184,20 +198,22 @@ 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);
@ -290,7 +306,8 @@ 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);
@ -366,7 +383,10 @@ 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({
@ -386,7 +406,9 @@ 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({
@ -419,15 +441,17 @@ 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

@ -2,7 +2,11 @@ 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";
import {
authMiddleware,
r2Middleware,
streamChatMiddleware,
} from "./middleware.js";
export const tabloDataRouter = new Hono<{
Variables: {

View file

@ -1,9 +1,12 @@
import { google } from "googleapis";
import nodemailer from "nodemailer";
import type { Transporter } from "nodemailer";
import { config } from "./config.js";
const OAuth2 = google.auth.OAuth2;
let _transporter: Transporter | null = null;
export const createTransporter = async () => {
const oauth2Client = new OAuth2(
config.EMAIL_CLIENT_ID,
@ -31,4 +34,22 @@ export const createTransporter = async () => {
return transporter;
};
export const transporter = await createTransporter();
// Lazy-loaded transporter to avoid top-level await
export async function getTransporter(): Promise<Transporter> {
if (!_transporter) {
_transporter = await createTransporter();
}
return _transporter;
}
// For backwards compatibility (will be deprecated)
export let transporter: Transporter | null = null;
// Initialize on first import (but not blocking)
getTransporter()
.then((t) => {
transporter = t;
})
.catch((error) => {
console.error("Failed to initialize transporter:", error);
});

View file

@ -93,7 +93,7 @@ userRouter.post("/mark-temporary", async (c) => {
}
try {
if (profile?.email) {
if (profile?.email && transporter) {
const mailOptions = {
from: "Xtablo <noreply@xtablo.com>",
to: profile.email,