xtablo-source/api/src/__tests__/tablo.test.ts
2025-10-11 12:32:52 +02:00

1287 lines
39 KiB
TypeScript

import { expect } from "chai";
import { afterEach, beforeEach, describe, it } from "mocha";
import sinon from "sinon";
import {
createMockContext,
createMockS3Client,
createMockStreamChatClient,
createMockSupabaseClient,
createMockTransporter,
mockEnvVars,
mockEvent,
mockProfile,
mockTablo,
mockUser,
} from "./test-utils.js";
describe("Tablo Router", () => {
// biome-ignore lint/suspicious/noExplicitAny: Mock client types
let mockSupabase: any;
// biome-ignore lint/suspicious/noExplicitAny: Mock client types
let mockStreamChat: any;
// biome-ignore lint/suspicious/noExplicitAny: Mock client types
let mockChannel: any;
// biome-ignore lint/suspicious/noExplicitAny: Mock client types
let mockS3: any;
let restoreEnv: () => void;
beforeEach(() => {
restoreEnv = mockEnvVars();
mockSupabase = createMockSupabaseClient();
const streamMocks = createMockStreamChatClient();
mockStreamChat = streamMocks.mockStreamChat;
mockChannel = streamMocks.mockChannel;
mockS3 = createMockS3Client();
});
afterEach(() => {
sinon.restore();
restoreEnv();
});
describe("POST /create", () => {
it("should create a new tablo with events", async () => {
const mockContext = createMockContext();
const payload = {
name: "New Tablo",
color: "bg-blue-500",
status: "todo",
events: [
{
title: "Event 1",
description: "Test event",
start_date: "2024-01-16",
start_time: "10:00",
end_time: "11:00",
},
],
};
mockContext.req.json.resolves(payload);
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
mockContext.get.withArgs("streamServerClient").returns(mockStreamChat);
// Mock Supabase insert
mockSupabase
.from()
.insert()
.select()
.single.resolves({ data: mockTablo, error: null });
// Mock events insert
const eventsBuilder = {
insert: sinon.stub().resolves({ data: [], error: null }),
};
mockSupabase.from.withArgs("events").returns(eventsBuilder);
// Create test handler
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const user = c.get("user");
const supabase = c.get("supabase");
const data = await c.req.json();
const { data: insertedTablo, error } = await supabase
.from("tablos")
.insert({
...data,
owner_id: user.id,
events: undefined,
})
.select()
.single();
if (error || !insertedTablo) {
return c.json(
{ error: error?.message || "Failed to create tablo" },
500
);
}
const streamServerClient = c.get("streamServerClient");
const channel = streamServerClient.channel(
"messaging",
insertedTablo.id,
{
name: insertedTablo.name,
created_by_id: user.id,
members: [user.id],
}
);
await channel.create();
if (data.events) {
// biome-ignore lint/suspicious/noExplicitAny: Event type varies
const eventsToInsert = data.events.map((event: any) => ({
...event,
tablo_id: insertedTablo.id,
created_by: user.id,
}));
await supabase.from("events").insert(eventsToInsert);
}
return c.json({ message: "Tablo created successfully" });
};
const result = await handler(mockContext);
expect(mockStreamChat.channel.calledOnce).to.be.true;
expect(mockChannel.create.calledOnce).to.be.true;
expect(result).to.deep.equal({ message: "Tablo created successfully" });
});
it("should return 500 if tablo creation fails", async () => {
const mockContext = createMockContext();
const payload = {
name: "New Tablo",
color: "bg-blue-500",
status: "todo",
};
mockContext.req.json.resolves(payload);
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
// Mock Supabase error
mockSupabase
.from()
.insert()
.select()
.single.resolves({ data: null, error: { message: "Insert failed" } });
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const user = c.get("user");
const supabase = c.get("supabase");
const data = await c.req.json();
const { error } = await supabase
.from("tablos")
.insert({
...data,
owner_id: user.id,
events: undefined,
})
.select()
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({ message: "Tablo created successfully" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ error: "Insert failed" });
});
});
describe("POST /create-and-invite", () => {
it("should create tablo and grant access to invited user", async () => {
const mockContext = createMockContext();
const ownerProfile = {
...mockProfile,
id: "owner-id",
short_user_id: "owner123",
};
const invitedProfile = { ...mockProfile, id: "invited-id" };
const payload = {
owner_short_id: "owner123",
event: {
title: "Meeting",
description: "Test meeting",
start_date: "2024-01-16",
start_time: "10:00",
end_time: "11:00",
},
};
mockContext.req.json.resolves(payload);
mockContext.get
.withArgs("user")
.returns({ ...mockUser, id: "invited-id" });
mockContext.get.withArgs("supabase").returns(mockSupabase);
mockContext.get.withArgs("streamServerClient").returns(mockStreamChat);
// Mock owner lookup
const ownerBuilder = {
select: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
single: sinon.stub().resolves({ data: ownerProfile, error: null }),
};
// Mock invited user lookup
const invitedBuilder = {
select: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
single: sinon.stub().resolves({ data: invitedProfile, error: null }),
};
// Mock existing tablo check
const existingTabloBuilder = {
select: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
is: sinon.stub().returnsThis(),
limit: sinon.stub().resolves({ data: [], error: null }),
};
// Mock tablo creation
const createTabloBuilder = {
insert: sinon.stub().returnsThis(),
select: sinon.stub().returnsThis(),
single: sinon.stub().resolves({ data: mockTablo, error: null }),
};
// Mock tablo access insert
const accessBuilder = {
insert: sinon.stub().resolves({ error: null }),
};
// Mock event insert
const eventBuilder = {
insert: sinon.stub().resolves({ error: null }),
};
let callCount = 0;
mockSupabase.from.callsFake((table: string) => {
callCount++;
if (table === "profiles" && callCount === 1) return ownerBuilder;
if (table === "profiles" && callCount === 2) return invitedBuilder;
if (table === "tablos" && callCount === 3) return existingTabloBuilder;
if (table === "tablos" && callCount === 4) return createTabloBuilder;
if (table === "tablo_access") return accessBuilder;
if (table === "events") return eventBuilder;
return createTabloBuilder;
});
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const data = await c.req.json();
if (!data.owner_short_id) {
return c.json({ error: "owner_id is required" }, 400);
}
if (!data.event) {
return c.json({ error: "event is required" }, 400);
}
const { data: ownerData, error: ownerError } = await supabase
.from("profiles")
.select("id, name, email")
.eq("short_user_id", data.owner_short_id)
.single();
const { data: invitedUser, error: invitedUserError } = await supabase
.from("profiles")
.select("id, name, email")
.eq("id", user.id)
.single();
if (ownerError || !ownerData || invitedUserError || !invitedUser) {
return c.json(
{ error: "owner_id or invited_user_id is incorrect" },
400
);
}
const ownerId = ownerData.id;
const { data: existingTablo, error: existingTabloError } =
await supabase
.from("tablos")
.select(
`
id,
name,
owner_id,
tablo_access!inner(user_id)
`
)
.eq("owner_id", ownerId)
.eq("tablo_access.user_id", user.id)
.is("deleted_at", null)
.limit(1);
if (existingTabloError) {
return c.json({ error: existingTabloError.message }, 500);
}
let tabloData: typeof mockTablo;
if (!existingTablo.length) {
const { data: insertedTablo, error } = await supabase
.from("tablos")
.insert({
name: `${invitedUser.name || "Invité"} / ${
ownerData.name || "Propriétaire"
}`,
color: "bg-blue-500",
status: "todo",
owner_id: ownerId,
})
.select()
.single();
if (error || !insertedTablo) {
return c.json(
{ error: error?.message || "Failed to create tablo" },
500
);
}
tabloData = insertedTablo;
} else {
tabloData = existingTablo[0];
}
const { error: tabloAccessError } = await supabase
.from("tablo_access")
.insert({
tablo_id: tabloData.id,
user_id: user.id,
is_admin: false,
is_active: true,
granted_by: ownerId,
});
if (tabloAccessError) {
return c.json({ error: tabloAccessError.message }, 500);
}
const channel = streamServerClient.channel("messaging", tabloData.id, {
name: tabloData.name,
created_by_id: ownerId,
members: [ownerId, user.id],
});
await channel.create();
await channel.sendMessage({
text: `🎉 Bienvenue dans votre nouveau tablo "${tabloData.name}" !`,
user_id: ownerId,
});
await supabase.from("events").insert({
...data.event,
tablo_id: tabloData.id,
created_by: ownerId,
});
return c.json({ id: tabloData.id });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ id: mockTablo.id });
expect(mockChannel.create.calledOnce).to.be.true;
expect(mockChannel.sendMessage.calledOnce).to.be.true;
});
it("should return 400 if owner_short_id is missing", async () => {
const mockContext = createMockContext();
mockContext.req.json.resolves({ event: {} });
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const data = await c.req.json();
if (!data.owner_short_id) {
return c.json({ error: "owner_id is required" }, 400);
}
return c.json({ message: "Success" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ error: "owner_id is required" });
});
it("should return 400 if event is missing", async () => {
const mockContext = createMockContext();
mockContext.req.json.resolves({ owner_short_id: "owner123" });
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const data = await c.req.json();
if (!data.owner_short_id) {
return c.json({ error: "owner_id is required" }, 400);
}
if (!data.event) {
return c.json({ error: "event is required" }, 400);
}
return c.json({ message: "Success" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ error: "event is required" });
});
});
describe("PATCH /update", () => {
it("should update tablo successfully", async () => {
const mockContext = createMockContext();
const updateData = {
id: mockTablo.id,
name: "Updated Tablo Name",
};
mockContext.req.json.resolves(updateData);
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
mockContext.get.withArgs("streamServerClient").returns(mockStreamChat);
const updatedTablo = { ...mockTablo, name: "Updated Tablo Name" };
mockSupabase
.from()
.update()
.eq()
.select()
.single.resolves({ data: updatedTablo, error: null });
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const data = await c.req.json();
const { id, ...tablo } = data;
const { data: update, error } = await supabase
.from("tablos")
.update(tablo)
.eq("id", id)
.eq("owner_id", user.id)
.select()
.single();
if (error || !update) {
return c.json({ error: error?.message || "Failed to update" }, 500);
}
const isUpdatingName =
tablo.name !== undefined && tablo.name !== update.name;
if (isUpdatingName) {
const channel = streamServerClient.channel("messaging", update.id);
try {
await channel.update({
name: update.name,
});
} catch (error) {
console.error("error updating channel", error);
}
}
return c.json({ message: "Tablo updated successfully" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ message: "Tablo updated successfully" });
});
it("should return 500 if update fails", async () => {
const mockContext = createMockContext();
const updateData = {
id: mockTablo.id,
name: "Updated Name",
};
mockContext.req.json.resolves(updateData);
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
mockSupabase
.from()
.update()
.eq()
.select()
.single.resolves({ data: null, error: { message: "Update failed" } });
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const user = c.get("user");
const supabase = c.get("supabase");
const data = await c.req.json();
const { id, ...tablo } = data;
const { error } = await supabase
.from("tablos")
.update(tablo)
.eq("id", id)
.eq("owner_id", user.id)
.select()
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({ message: "Tablo updated successfully" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ error: "Update failed" });
});
});
describe("DELETE /delete", () => {
it("should soft delete tablo successfully", async () => {
const mockContext = createMockContext();
mockContext.req.json.resolves({ id: mockTablo.id });
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
mockContext.get.withArgs("streamServerClient").returns(mockStreamChat);
const updateBuilder = {
update: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
};
updateBuilder.eq.onCall(1).resolves({ error: null });
mockSupabase.from.withArgs("tablos").returns(updateBuilder);
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const data = await c.req.json();
const { id } = data;
const { error } = await supabase
.from("tablos")
.update({ deleted_at: new Date().toISOString() })
.eq("id", id)
.eq("owner_id", user.id);
if (error) {
return c.json({ error: error.message }, 500);
}
const channel = streamServerClient.channel("messaging", id);
try {
await channel.delete();
} catch (error) {
console.error("error deleting channel", error);
}
return c.json({ message: "Tablo deleted successfully" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ message: "Tablo deleted successfully" });
expect(mockChannel.delete.calledOnce).to.be.true;
});
it("should return 500 if delete fails", async () => {
const mockContext = createMockContext();
mockContext.req.json.resolves({ id: mockTablo.id });
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
const updateBuilder = {
update: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
};
updateBuilder.eq
.onCall(1)
.resolves({ error: { message: "Delete failed" } });
mockSupabase.from.withArgs("tablos").returns(updateBuilder);
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const user = c.get("user");
const supabase = c.get("supabase");
const data = await c.req.json();
const { id } = data;
const { error } = await supabase
.from("tablos")
.update({ deleted_at: new Date().toISOString() })
.eq("id", id)
.eq("owner_id", user.id);
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({ message: "Tablo deleted successfully" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ error: "Delete failed" });
});
});
describe("POST /invite", () => {
it("should send invite successfully", async () => {
const mockContext = createMockContext();
mockContext.req.json.resolves({
email: "invitee@example.com",
tablo_id: mockTablo.id,
});
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
// Mock tablo lookup
mockSupabase
.from()
.select()
.eq()
.single.resolves({ data: mockTablo, error: null });
// Mock invite insert
const inviteBuilder = {
insert: sinon.stub().resolves({ error: null }),
};
mockSupabase.from.withArgs("tablo_invites").returns(inviteBuilder);
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const sender = c.get("user");
const supabase = c.get("supabase");
const { tablo_id, email } = await c.req.json();
const { data, error: tabloError } = await supabase
.from("tablos")
.select("*")
.eq("id", tablo_id)
.single();
if (tabloError) {
return c.json({ error: tabloError.message }, 500);
}
if (!data) {
return c.json({ error: "Tablo not found" }, 404);
}
if (data.owner_id !== sender.id) {
return c.json(
{ error: "You are not allowed to invite users to this tablo" },
400
);
}
const { error } = await supabase.from("tablo_invites").insert({
invited_email: email,
tablo_id: tablo_id,
invited_by: sender.id,
invite_token: "mock-token",
});
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
message: "Invite sent successfully",
});
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ message: "Invite sent successfully" });
});
it("should return 404 if tablo not found", async () => {
const mockContext = createMockContext();
mockContext.req.json.resolves({
email: "invitee@example.com",
tablo_id: "non-existent",
});
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
mockSupabase
.from()
.select()
.eq()
.single.resolves({ data: null, error: null });
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const _sender = c.get("user");
const supabase = c.get("supabase");
const { tablo_id } = await c.req.json();
const { data, error: tabloError } = await supabase
.from("tablos")
.select("*")
.eq("id", tablo_id)
.single();
if (tabloError) {
return c.json({ error: tabloError.message }, 500);
}
if (!data) {
return c.json({ error: "Tablo not found" }, 404);
}
return c.json({ message: "Success" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ error: "Tablo not found" });
});
it("should return 400 if user is not owner", async () => {
const mockContext = createMockContext();
mockContext.req.json.resolves({
email: "invitee@example.com",
tablo_id: mockTablo.id,
});
mockContext.get
.withArgs("user")
.returns({ ...mockUser, id: "different-user" });
mockContext.get.withArgs("supabase").returns(mockSupabase);
mockSupabase
.from()
.select()
.eq()
.single.resolves({ data: mockTablo, error: null });
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const sender = c.get("user");
const supabase = c.get("supabase");
const { tablo_id } = await c.req.json();
const { data, error: tabloError } = await supabase
.from("tablos")
.select("*")
.eq("id", tablo_id)
.single();
if (tabloError) {
return c.json({ error: tabloError.message }, 500);
}
if (!data) {
return c.json({ error: "Tablo not found" }, 404);
}
if (data.owner_id !== sender.id) {
return c.json(
{ error: "You are not allowed to invite users to this tablo" },
400
);
}
return c.json({ message: "Success" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({
error: "You are not allowed to invite users to this tablo",
});
});
});
describe("POST /join", () => {
it("should join tablo successfully with valid token", async () => {
const mockContext = createMockContext();
mockContext.req.json.resolves({ token: "valid-token" });
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
mockContext.get.withArgs("streamServerClient").returns(mockStreamChat);
const inviteData = {
id: "invite-id",
tablo_id: mockTablo.id,
invited_by: "inviter-id",
};
// Mock invite lookup
const inviteSelectBuilder = {
select: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
single: sinon.stub().resolves({ data: inviteData, error: null }),
};
// Mock tablo access insert
const accessBuilder = {
insert: sinon.stub().resolves({ error: null }),
};
// Mock invite delete
const deleteBuilder = {
delete: sinon.stub().returnsThis(),
eq: sinon.stub().resolves({ error: null }),
};
let callCount = 0;
mockSupabase.from.callsFake((table: string) => {
callCount++;
if (table === "tablo_invites" && callCount === 1) {
return inviteSelectBuilder;
}
if (table === "tablo_access") return accessBuilder;
if (table === "tablo_invites" && callCount > 1) return deleteBuilder;
return mockSupabase.from();
});
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const { token } = await c.req.json();
const joiner = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { data: inviteData, error } = await supabase
.from("tablo_invites")
.select("id, tablo_id, invited_by")
.eq("invite_token", token)
.eq("invited_email", joiner.email)
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
if (!inviteData) {
return c.json({ error: "Invalid token or email" }, 400);
}
const { id: invite_id, tablo_id, invited_by } = inviteData;
const { error: tabloAccessError } = await supabase
.from("tablo_access")
.insert({
tablo_id,
user_id: joiner.id,
is_admin: false,
is_active: true,
granted_by: invited_by,
});
if (tabloAccessError) {
return c.json({ error: tabloAccessError.message }, 500);
}
await supabase.from("tablo_invites").delete().eq("id", invite_id);
const channel = streamServerClient.channel("messaging", tablo_id);
await channel.addMembers([joiner.id]);
return c.json({ message: "Tablo joined successfully" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ message: "Tablo joined successfully" });
expect(mockChannel.addMembers.calledOnce).to.be.true;
});
it("should return 400 for invalid token", async () => {
const mockContext = createMockContext();
mockContext.req.json.resolves({ token: "invalid-token" });
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
mockSupabase
.from()
.select()
.eq()
.single.resolves({ data: null, error: null });
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const { token } = await c.req.json();
const joiner = c.get("user");
const supabase = c.get("supabase");
const { data: inviteData, error } = await supabase
.from("tablo_invites")
.select("id, tablo_id, invited_by")
.eq("invite_token", token)
.eq("invited_email", joiner.email)
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
if (!inviteData) {
return c.json({ error: "Invalid token or email" }, 400);
}
return c.json({ message: "Success" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ error: "Invalid token or email" });
});
});
describe("GET /members/:tablo_id", () => {
it("should return tablo members", async () => {
const mockContext = createMockContext();
mockContext.req.param.withArgs("tablo_id").returns(mockTablo.id);
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
const members = [
{ is_admin: true, profiles: { id: "user1", name: "User 1" } },
{ is_admin: false, profiles: { id: "user2", name: "User 2" } },
];
// Mock user_tablos check
const userTablosBuilder = {
select: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
};
// The second eq() call should resolve
userTablosBuilder.eq
.onCall(1)
.resolves({ data: [mockTablo], error: null });
// Mock tablo_access query
const accessBuilder = {
select: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
};
// The second eq() call should resolve
accessBuilder.eq.onCall(1).resolves({ data: members, error: null });
mockSupabase.from.callsFake((table: string) => {
if (table === "user_tablos") return userTablosBuilder;
if (table === "tablo_access") return accessBuilder;
return mockSupabase.from();
});
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const user = c.get("user");
const supabase = c.get("supabase");
const tablo_id = c.req.param("tablo_id");
const { data: tabloData, error: tabloError } = await supabase
.from("user_tablos")
.select("*")
.eq("id", tablo_id)
.eq("user_id", user.id);
if (!tabloData || tabloData.length === 0) {
return c.json({ error: "You are not a member of this tablo" }, 403);
}
if (tabloError) {
return c.json({ error: "Internal server error" }, 500);
}
const { data, error } = await supabase
.from("tablo_access")
.select("is_admin, profiles(id, name)")
.eq("tablo_id", tablo_id)
.eq("is_active", true);
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
// biome-ignore lint/suspicious/noExplicitAny: Member type from DB
members: data.map((member: any) => ({
...member.profiles,
is_admin: member.is_admin,
})),
});
};
const result = await handler(mockContext);
expect(result.members).to.have.length(2);
expect(result.members[0]).to.deep.equal({
id: "user1",
name: "User 1",
is_admin: true,
});
});
it("should return 403 if user is not a member", async () => {
const mockContext = createMockContext();
mockContext.req.param.withArgs("tablo_id").returns(mockTablo.id);
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
const userTablosBuilder = {
select: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
single: sinon.stub().resolves({ data: [], error: null }),
};
mockSupabase.from.withArgs("user_tablos").returns(userTablosBuilder);
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const user = c.get("user");
const supabase = c.get("supabase");
const tablo_id = c.req.param("tablo_id");
const { data: tabloData } = await supabase
.from("user_tablos")
.select("*")
.eq("id", tablo_id)
.eq("user_id", user.id);
if (!tabloData || tabloData.length === 0) {
return c.json({ error: "You are not a member of this tablo" }, 403);
}
return c.json({ message: "Success" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({
error: "You are not a member of this tablo",
});
});
});
describe("POST /leave", () => {
it("should leave tablo successfully", async () => {
const mockContext = createMockContext();
mockContext.req.json.resolves({ tablo_id: mockTablo.id });
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
mockContext.get.withArgs("streamServerClient").returns(mockStreamChat);
const updateBuilder = {
update: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
};
// The second eq() call should resolve
updateBuilder.eq.onCall(1).resolves({ error: null });
mockSupabase.from.withArgs("tablo_access").returns(updateBuilder);
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { tablo_id } = await c.req.json();
const channel = streamServerClient.channel("messaging", tablo_id);
await channel.removeMembers([user.id]);
const { error } = await supabase
.from("tablo_access")
.update({ is_active: false })
.eq("tablo_id", tablo_id)
.eq("user_id", user.id);
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({ message: "Tablo left successfully" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ message: "Tablo left successfully" });
expect(mockChannel.removeMembers.calledOnce).to.be.true;
});
});
describe("POST /webcal/generate-url", () => {
it("should generate webcal URL for tablo", async () => {
const mockContext = createMockContext();
mockContext.req.json.resolves({ tablo_id: mockTablo.id });
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
mockContext.get.withArgs("s3_client").returns(mockS3);
// Mock tablo lookup
const tabloBuilder = {
select: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
single: sinon.stub().resolves({ data: mockTablo, error: null }),
};
// Mock access check
const accessBuilder = {
select: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
single: sinon
.stub()
.resolves({ data: { id: mockTablo.id }, error: null }),
};
// Mock subscription check (no existing subscription)
const subscriptionBuilder = {
select: sinon.stub().returnsThis(),
eq: sinon.stub().returnsThis(),
single: sinon.stub().resolves({ data: null, error: null }),
};
// Mock subscription insert
const insertBuilder = {
insert: sinon.stub().resolves({ error: null }),
};
let callCount = 0;
mockSupabase.from.callsFake((table: string) => {
callCount++;
if (table === "tablos") return tabloBuilder;
if (table === "user_tablos") return accessBuilder;
if (table === "calendar_subscriptions" && callCount === 3)
return subscriptionBuilder;
if (table === "calendar_subscriptions" && callCount === 4)
return insertBuilder;
if (table === "events_and_tablos") {
return {
select: sinon.stub().returnsThis(),
eq: sinon.stub().resolves({ data: [], error: null }),
};
}
return mockSupabase.from();
});
mockS3.send.resolves({});
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const user = c.get("user");
const supabase = c.get("supabase");
const { tablo_id } = await c.req.json();
if (tablo_id === null) {
return c.json({ error: "All tablos are not supported" }, 400);
}
const { data: tabloData, error: tabloError } = await supabase
.from("tablos")
.select("name")
.eq("id", tablo_id)
.single();
if (tabloError || !tabloData) {
return c.json({ error: "Tablo not found" }, 404);
}
const { data: accessData, error: accessError } = await supabase
.from("user_tablos")
.select("id")
.eq("id", tablo_id)
.eq("user_id", user.id)
.single();
if (accessError || !accessData) {
return c.json({ error: "Access denied to this tablo" }, 403);
}
const { data: subscriptionData } = await supabase
.from("calendar_subscriptions")
.select("*")
.eq("tablo_id", tablo_id)
.single();
if (subscriptionData) {
const token = subscriptionData.token;
const tabloName = tabloData.name.replace(/ /g, "_");
const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`;
return c.json({
webcal_url: null,
http_url: httpUrl,
});
}
const token = "mock-token";
const { error } = await supabase.from("calendar_subscriptions").insert({
tablo_id: tablo_id,
token: token,
});
if (error) {
return c.json({ error: "Failed to generate token" }, 500);
}
const tabloName = tabloData.name.replace(/ /g, "_");
const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`;
return c.json({
webcal_url: null,
http_url: httpUrl,
});
};
const result = await handler(mockContext);
expect(result.http_url).to.include("https://calendar.xtablo.com/");
expect(result.http_url).to.include(".ics");
});
it("should return 404 if tablo not found", async () => {
const mockContext = createMockContext();
mockContext.req.json.resolves({ tablo_id: "non-existent" });
mockContext.get.withArgs("user").returns(mockUser);
mockContext.get.withArgs("supabase").returns(mockSupabase);
mockSupabase
.from()
.select()
.eq()
.single.resolves({ data: null, error: { message: "Not found" } });
// biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context
const handler = async (c: any) => {
const _user = c.get("user");
const supabase = c.get("supabase");
const { tablo_id } = await c.req.json();
const { data: tabloData, error: tabloError } = await supabase
.from("tablos")
.select("name")
.eq("id", tablo_id)
.single();
if (tabloError || !tabloData) {
return c.json({ error: "Tablo not found" }, 404);
}
return c.json({ message: "Success" });
};
const result = await handler(mockContext);
expect(result).to.deep.equal({ error: "Tablo not found" });
});
});
});