Add db migrations + tests

This commit is contained in:
Arthur Belleville 2025-11-16 20:34:33 +01:00
parent f4705f23a7
commit e1f673be47
No known key found for this signature in database
7 changed files with 941 additions and 9 deletions

View file

@ -1,3 +1,4 @@
import { createClient } from "@supabase/supabase-js";
import { testClient } from "hono/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createConfig } from "../../config.js";
@ -35,6 +36,19 @@ vi.mock("stream-chat", () => {
};
});
// Mock nodemailer for email sending
const mockSendMail = vi.fn();
vi.mock("nodemailer", () => ({
default: {
createTransport: vi.fn(() => ({
sendMail: mockSendMail,
})),
},
createTransport: vi.fn(() => ({
sendMail: mockSendMail,
})),
}));
describe("Tablo Endpoint", () => {
// In test mode, createConfig() reads from .env.test
const config = createConfig();
@ -54,6 +68,7 @@ describe("Tablo Endpoint", () => {
mockChannelDelete.mockResolvedValue(undefined);
mockChannelRemoveMembers.mockResolvedValue(undefined);
mockChannelAddMembers.mockResolvedValue(undefined);
mockSendMail.mockResolvedValue({ messageId: "test-message-id" });
});
// Helper function to create tablo
@ -147,6 +162,28 @@ describe("Tablo Endpoint", () => {
);
};
// Helper function to invite user to tablo
const inviteToTabloRequest = async (
user: TestUserData,
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
client: any,
tabloId: string,
email: string
) => {
return await client.tablos.invite[":tabloId"].$post(
{
param: { tabloId },
json: { email },
},
{
headers: {
Authorization: `Bearer ${user.accessToken}`,
"Content-Type": "application/json",
},
}
);
};
describe("POST /tablos/create - Create Tablo", () => {
it("should allow owner to create a tablo and create a Stream Chat channel", async () => {
const res = await createTabloRequest(ownerUser, client, {
@ -436,4 +473,100 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(401);
});
});
describe("POST /tablos/invite/:tabloId - Invite User to Tablo", () => {
it("should create a notification when inviting an existing user to a tablo", async () => {
// Create a Supabase client to query the database
const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
// Get temp user's email
const tempUserEmail = temporaryUser.email;
// Count notifications for temp user before invite
const { data: notificationsBefore } = await supabaseAdmin
.from("notifications")
.select("*")
.eq("user_id", temporaryUser.userId)
.eq("entity_type", "tablo_invites");
const countBefore = notificationsBefore?.length || 0;
// Owner invites temp user to owner's private tablo (temp doesn't have access yet)
const res = await inviteToTabloRequest(
ownerUser,
client,
"test_tablo_owner_private",
tempUserEmail
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toContain("sent successfully");
// Query notifications table to verify notification was created
const { data: notificationsAfter } = await supabaseAdmin
.from("notifications")
.select("*")
.eq("user_id", temporaryUser.userId)
.eq("entity_type", "tablo_invites")
.order("created_at", { ascending: false });
expect(notificationsAfter).toBeDefined();
expect(notificationsAfter?.length).toBeGreaterThan(countBefore);
// Verify the latest notification has correct properties
const latestNotification = notificationsAfter?.[0];
expect(latestNotification).toBeDefined();
expect(latestNotification?.entity_type).toBe("tablo_invites");
expect(latestNotification?.user_id).toBe(temporaryUser.userId);
expect(latestNotification?.actor_id).toBe(ownerUser.userId);
expect(latestNotification?.action_type).toBe("created");
expect(latestNotification?.message).toContain("invited");
expect(latestNotification?.read_at).toBeNull();
});
it("should create notification when inviting non-existent user (creates temporary account)", async () => {
// Create a Supabase client to query the database
const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
const nonExistentEmail = "nonexistent_user_test@example.com";
// Owner invites non-existent user to owner's private tablo
const res = await inviteToTabloRequest(
ownerUser,
client,
"test_tablo_owner_private_2",
nonExistentEmail
);
// Request should succeed (creates invite)
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toContain("sent successfully");
// Verify user was created with a temporary account
const { data: authUsers } = await supabaseAdmin.auth.admin.listUsers();
const createdUser = authUsers.users.find(
(u: { email: string }) => u.email === nonExistentEmail
);
expect(createdUser).toBeDefined();
// Check if notification was created for the newly created user
// Since the system creates a temporary account, a notification should be created
const { data: notificationsForInvite } = await supabaseAdmin
.from("notifications")
.select("*")
.eq("user_id", createdUser?.id)
.eq("entity_type", "tablo_invites")
.contains("metadata", { invited_email: nonExistentEmail });
// Should create notification for the newly created temporary user
expect(notificationsForInvite?.length || 0).toBeGreaterThan(0);
expect(notificationsForInvite?.[0].message).toContain("invited");
});
});
});

View file

@ -386,8 +386,9 @@ describe("TabloData Endpoint", () => {
expect(data.message).toBe("File deleted successfully");
});
it("should deny owner with admin access to delete file from temp's tablo", async () => {
// Owner does not have admin access to test_tablo_temp_shared_admin
it("should deny owner from deleting file from temp's tablo (regularUserCheck blocks temporary owner)", async () => {
// Owner has admin access to test_tablo_temp_shared_admin
// BUT regularUserCheck blocks access to tablos owned by temporary users
const res = await deleteTabloFileRequest(
ownerUser,
client,

View file

@ -248,11 +248,12 @@ BEGIN
END IF;
END IF;
-- Find the user by email and notify them
-- Find the user by email and notify them (case-insensitive)
-- Only notify if user exists in the system
affected_users := ARRAY(
SELECT id
FROM auth.users
WHERE email = NEW.invited_email
WHERE LOWER(email) = LOWER(NEW.invited_email)
AND id != actor
);

View file

@ -0,0 +1,262 @@
CREATE OR REPLACE FUNCTION public.notify_users()
RETURNS TRIGGER AS $$
DECLARE
affected_users UUID[];
affected_user UUID;
actor UUID;
action TEXT;
msg TEXT;
meta JSONB;
entity_type_name TEXT;
entity_identifier TEXT;
BEGIN
-- Determine if this is an INSERT or UPDATE
IF TG_OP = 'INSERT' THEN
action := 'created';
ELSE
action := 'updated';
-- Skip if soft delete happened (only for tables with deleted_at column)
IF TG_TABLE_NAME IN ('tablos', 'events', 'notes', 'event_types') THEN
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN
RETURN NEW;
END IF;
END IF;
END IF;
-- Get the actor (current user)
actor := auth.uid();
-- Set entity type and ID based on the table
entity_type_name := TG_TABLE_NAME;
-- Determine entity ID and affected users based on table
CASE TG_TABLE_NAME
WHEN 'tablos' THEN
entity_identifier := NEW.id;
IF TG_OP = 'INSERT' THEN
msg := 'New tablo "' || NEW.name || '" was created';
-- Notify owner (but not if they are the creator)
IF NEW.owner_id != actor THEN
affected_users := ARRAY[NEW.owner_id];
END IF;
ELSE
msg := 'Tablo "' || NEW.name || '" was updated';
-- Notify owner and all collaborators
affected_users := ARRAY(
SELECT DISTINCT user_id
FROM public.tablo_access
WHERE tablo_id = NEW.id
AND is_active = true
AND user_id != actor
UNION
SELECT NEW.owner_id
WHERE NEW.owner_id != actor
);
END IF;
meta := jsonb_build_object(
'tablo_name', NEW.name,
'status', NEW.status,
'color', NEW.color
);
WHEN 'tasks' THEN
entity_identifier := NEW.id;
IF TG_OP = 'INSERT' THEN
msg := 'New task "' || NEW.title || '" was created';
ELSE
IF OLD.status != NEW.status THEN
msg := 'Task "' || NEW.title || '" status changed to ' || NEW.status;
ELSIF OLD.assignee_id != NEW.assignee_id OR (OLD.assignee_id IS NULL AND NEW.assignee_id IS NOT NULL) THEN
msg := 'You were assigned to task "' || NEW.title || '"';
ELSE
msg := 'Task "' || NEW.title || '" was updated';
END IF;
END IF;
-- Notify tablo collaborators and assignee
affected_users := ARRAY(
SELECT DISTINCT user_id
FROM public.tablo_access
WHERE tablo_id = NEW.tablo_id
AND is_active = true
AND user_id != actor
UNION
SELECT t.owner_id
FROM public.tablos t
WHERE t.id = NEW.tablo_id
AND t.owner_id != actor
UNION
SELECT NEW.assignee_id
WHERE NEW.assignee_id IS NOT NULL
AND NEW.assignee_id != actor
);
meta := jsonb_build_object(
'task_title', NEW.title,
'status', NEW.status,
'tablo_id', NEW.tablo_id,
'assignee_id', NEW.assignee_id
);
WHEN 'events' THEN
entity_identifier := NEW.id;
IF TG_OP = 'INSERT' THEN
msg := 'New event "' || NEW.title || '" was created';
ELSE
msg := 'Event "' || NEW.title || '" was updated';
END IF;
-- Notify tablo collaborators
affected_users := ARRAY(
SELECT DISTINCT user_id
FROM public.tablo_access
WHERE tablo_id = NEW.tablo_id
AND is_active = true
AND user_id != actor
UNION
SELECT t.owner_id
FROM public.tablos t
WHERE t.id = NEW.tablo_id
AND t.owner_id != actor
UNION
SELECT NEW.created_by
WHERE NEW.created_by != actor
);
meta := jsonb_build_object(
'event_title', NEW.title,
'start_date', NEW.start_date,
'start_time', NEW.start_time,
'tablo_id', NEW.tablo_id
);
WHEN 'notes' THEN
entity_identifier := NEW.id;
IF TG_OP = 'INSERT' THEN
msg := 'New note "' || NEW.title || '" was created';
ELSE
msg := 'Note "' || NEW.title || '" was updated';
END IF;
-- Notify note owner and users with access
affected_users := ARRAY(
SELECT DISTINCT user_id
FROM public.note_access
WHERE note_id = NEW.id
AND is_active = true
AND user_id != actor
UNION
SELECT NEW.user_id
WHERE NEW.user_id != actor
);
meta := jsonb_build_object(
'note_title', NEW.title,
'note_owner', NEW.user_id
);
WHEN 'tablo_access' THEN
entity_identifier := NEW.id::TEXT;
IF TG_OP = 'INSERT' THEN
msg := 'You were granted access to a tablo';
ELSE
IF OLD.is_admin != NEW.is_admin AND NEW.is_admin = true THEN
msg := 'You were promoted to admin on a tablo';
ELSIF OLD.is_active != NEW.is_active AND NEW.is_active = false THEN
msg := 'Your access to a tablo was revoked';
ELSE
msg := 'Your tablo access was updated';
END IF;
END IF;
-- Notify the user being granted/modified access
IF NEW.user_id != actor THEN
affected_users := ARRAY[NEW.user_id];
END IF;
meta := jsonb_build_object(
'tablo_id', NEW.tablo_id,
'is_admin', NEW.is_admin,
'is_active', NEW.is_active,
'granted_by', NEW.granted_by
);
WHEN 'tablo_invites' THEN
entity_identifier := NEW.id::TEXT;
RAISE LOG 'notify_users: Processing tablo_invites, TG_OP=%, email=%', TG_OP, NEW.invited_email;
IF TG_OP = 'INSERT' THEN
msg := 'You were invited to collaborate on a tablo';
ELSE
IF OLD.is_pending != NEW.is_pending AND NEW.is_pending = false THEN
msg := 'Your tablo invitation status changed';
ELSE
msg := 'Your tablo invitation was updated';
END IF;
END IF;
RAISE LOG 'notify_users: Looking for user with email=%, actor=%', NEW.invited_email, actor;
-- Find the user by email and notify them (case-insensitive)
-- Only notify if user exists in the system
affected_users := ARRAY(
SELECT id
FROM auth.users
WHERE LOWER(email) = LOWER(NEW.invited_email)
AND id != actor
);
RAISE LOG 'notify_users: Found % affected users for invite', COALESCE(array_length(affected_users, 1), 0);
meta := jsonb_build_object(
'tablo_id', NEW.tablo_id,
'invited_email', NEW.invited_email,
'invited_by', NEW.invited_by,
'is_pending', NEW.is_pending
);
ELSE
-- Unknown table, skip
RETURN NEW;
END CASE;
-- Insert notifications for all affected users
RAISE LOG 'notify_users: About to insert notifications for % users, entity_type=%', COALESCE(array_length(affected_users, 1), 0), entity_type_name;
IF affected_users IS NOT NULL AND array_length(affected_users, 1) > 0 THEN
FOREACH affected_user IN ARRAY affected_users
LOOP
RAISE LOG 'notify_users: Inserting notification for user_id=%, entity_type=%, message=%', affected_user, entity_type_name, msg;
INSERT INTO public.notifications (
user_id,
actor_id,
entity_type,
entity_id,
action_type,
message,
metadata
) VALUES (
affected_user,
actor,
entity_type_name,
entity_identifier,
action,
msg,
meta
);
END LOOP;
ELSE
RAISE LOG 'notify_users: No affected users to notify';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View file

@ -0,0 +1,270 @@
CREATE OR REPLACE FUNCTION public.notify_users()
RETURNS TRIGGER AS $$
DECLARE
affected_users UUID[];
affected_user UUID;
actor UUID;
action TEXT;
msg TEXT;
meta JSONB;
entity_type_name TEXT;
entity_identifier TEXT;
BEGIN
-- Determine if this is an INSERT or UPDATE
IF TG_OP = 'INSERT' THEN
action := 'created';
ELSE
action := 'updated';
-- Skip if soft delete happened (only for tables with deleted_at column)
IF TG_TABLE_NAME IN ('tablos', 'events', 'notes', 'event_types') THEN
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN
RETURN NEW;
END IF;
END IF;
END IF;
-- Get the actor (current user)
-- Note: auth.uid() may return NULL when called via service role (API calls)
-- In such cases, we won't exclude anyone from notifications based on actor
actor := auth.uid();
-- Set entity type and ID based on the table
entity_type_name := TG_TABLE_NAME;
-- Determine entity ID and affected users based on table
CASE TG_TABLE_NAME
WHEN 'tablos' THEN
entity_identifier := NEW.id;
IF TG_OP = 'INSERT' THEN
msg := 'New tablo "' || NEW.name || '" was created';
-- Notify owner (but not if they are the creator)
IF actor IS NULL OR NEW.owner_id != actor THEN
affected_users := ARRAY[NEW.owner_id];
END IF;
ELSE
msg := 'Tablo "' || NEW.name || '" was updated';
-- Notify owner and all collaborators (exclude actor if set)
affected_users := ARRAY(
SELECT DISTINCT user_id
FROM public.tablo_access
WHERE tablo_id = NEW.id
AND is_active = true
AND (actor IS NULL OR user_id != actor)
UNION
SELECT NEW.owner_id
WHERE actor IS NULL OR NEW.owner_id != actor
);
END IF;
meta := jsonb_build_object(
'tablo_name', NEW.name,
'status', NEW.status,
'color', NEW.color
);
WHEN 'tasks' THEN
entity_identifier := NEW.id;
IF TG_OP = 'INSERT' THEN
msg := 'New task "' || NEW.title || '" was created';
ELSE
IF OLD.status != NEW.status THEN
msg := 'Task "' || NEW.title || '" status changed to ' || NEW.status;
ELSIF OLD.assignee_id != NEW.assignee_id OR (OLD.assignee_id IS NULL AND NEW.assignee_id IS NOT NULL) THEN
msg := 'You were assigned to task "' || NEW.title || '"';
ELSE
msg := 'Task "' || NEW.title || '" was updated';
END IF;
END IF;
-- Notify tablo collaborators and assignee (exclude actor if set)
affected_users := ARRAY(
SELECT DISTINCT user_id
FROM public.tablo_access
WHERE tablo_id = NEW.tablo_id
AND is_active = true
AND (actor IS NULL OR user_id != actor)
UNION
SELECT t.owner_id
FROM public.tablos t
WHERE t.id = NEW.tablo_id
AND (actor IS NULL OR t.owner_id != actor)
UNION
SELECT NEW.assignee_id
WHERE NEW.assignee_id IS NOT NULL
AND (actor IS NULL OR NEW.assignee_id != actor)
);
meta := jsonb_build_object(
'task_title', NEW.title,
'status', NEW.status,
'tablo_id', NEW.tablo_id,
'assignee_id', NEW.assignee_id
);
WHEN 'events' THEN
entity_identifier := NEW.id;
IF TG_OP = 'INSERT' THEN
msg := 'New event "' || NEW.title || '" was created';
ELSE
msg := 'Event "' || NEW.title || '" was updated';
END IF;
-- Notify tablo collaborators (exclude actor if set)
affected_users := ARRAY(
SELECT DISTINCT user_id
FROM public.tablo_access
WHERE tablo_id = NEW.tablo_id
AND is_active = true
AND (actor IS NULL OR user_id != actor)
UNION
SELECT t.owner_id
FROM public.tablos t
WHERE t.id = NEW.tablo_id
AND (actor IS NULL OR t.owner_id != actor)
UNION
SELECT NEW.created_by
WHERE actor IS NULL OR NEW.created_by != actor
);
meta := jsonb_build_object(
'event_title', NEW.title,
'start_date', NEW.start_date,
'start_time', NEW.start_time,
'tablo_id', NEW.tablo_id
);
WHEN 'notes' THEN
entity_identifier := NEW.id;
IF TG_OP = 'INSERT' THEN
msg := 'New note "' || NEW.title || '" was created';
ELSE
msg := 'Note "' || NEW.title || '" was updated';
END IF;
-- Notify note owner and users with access (exclude actor if set)
affected_users := ARRAY(
SELECT DISTINCT user_id
FROM public.note_access
WHERE note_id = NEW.id
AND is_active = true
AND (actor IS NULL OR user_id != actor)
UNION
SELECT NEW.user_id
WHERE actor IS NULL OR NEW.user_id != actor
);
meta := jsonb_build_object(
'note_title', NEW.title,
'note_owner', NEW.user_id
);
WHEN 'tablo_access' THEN
entity_identifier := NEW.id::TEXT;
IF TG_OP = 'INSERT' THEN
msg := 'You were granted access to a tablo';
ELSE
IF OLD.is_admin != NEW.is_admin AND NEW.is_admin = true THEN
msg := 'You were promoted to admin on a tablo';
ELSIF OLD.is_active != NEW.is_active AND NEW.is_active = false THEN
msg := 'Your access to a tablo was revoked';
ELSE
msg := 'Your tablo access was updated';
END IF;
END IF;
-- Notify the user being granted/modified access (exclude actor if set)
IF actor IS NULL OR NEW.user_id != actor THEN
affected_users := ARRAY[NEW.user_id];
END IF;
meta := jsonb_build_object(
'tablo_id', NEW.tablo_id,
'is_admin', NEW.is_admin,
'is_active', NEW.is_active,
'granted_by', NEW.granted_by
);
WHEN 'tablo_invites' THEN
entity_identifier := NEW.id::TEXT;
-- For tablo_invites, use invited_by as actor instead of auth.uid()
-- because this is called via service role which has no auth context
actor := NEW.invited_by;
RAISE LOG 'notify_users: New tablo invite: %', NEW;
RAISE LOG 'notify_users: Processing tablo_invites, TG_OP=%, email=%, actor=%', TG_OP, NEW.invited_email, actor;
IF TG_OP = 'INSERT' THEN
msg := 'You were invited to collaborate on a tablo';
ELSE
IF OLD.is_pending != NEW.is_pending AND NEW.is_pending = false THEN
msg := 'Your tablo invitation status changed';
ELSE
msg := 'Your tablo invitation was updated';
END IF;
END IF;
RAISE LOG 'notify_users: Looking for user with email=%, actor=%', NEW.invited_email, actor;
-- Find the user by email and notify them (case-insensitive)
-- Only notify if user exists in the system
-- Don't notify the person who sent the invite (exclude actor if set)
affected_users := ARRAY(
SELECT id
FROM auth.users
WHERE LOWER(email) = LOWER(NEW.invited_email)
AND (actor IS NULL OR id != actor)
);
RAISE LOG 'notify_users: Found % affected users for invite', COALESCE(array_length(affected_users, 1), 0);
meta := jsonb_build_object(
'tablo_id', NEW.tablo_id,
'invited_email', NEW.invited_email,
'invited_by', NEW.invited_by,
'is_pending', NEW.is_pending
);
ELSE
-- Unknown table, skip
RETURN NEW;
END CASE;
-- Insert notifications for all affected users
RAISE LOG 'notify_users: About to insert notifications for % users, entity_type=%', COALESCE(array_length(affected_users, 1), 0), entity_type_name;
IF affected_users IS NOT NULL AND array_length(affected_users, 1) > 0 THEN
FOREACH affected_user IN ARRAY affected_users
LOOP
RAISE LOG 'notify_users: Inserting notification for user_id=%, entity_type=%, message=%', affected_user, entity_type_name, msg;
INSERT INTO public.notifications (
user_id,
actor_id,
entity_type,
entity_id,
action_type,
message,
metadata
) VALUES (
affected_user,
actor,
entity_type_name,
entity_identifier,
action,
msg,
meta
);
END LOOP;
ELSE
RAISE LOG 'notify_users: No affected users to notify';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View file

@ -109,7 +109,7 @@ supabase/tests/database/03_rls_policies_notes.test.sql .. ok
supabase/tests/database/09_notifications.test.sql .. ok
supabase/tests/database/10_rls_policies_notifications.test.sql .. ok
All tests successful.
Files=10, Tests=374, 1 wallclock secs
Files=10, Tests=382, 1 wallclock secs
Result: PASS
```
@ -155,7 +155,7 @@ Use the pgTAP documentation for available test functions: https://pgtap.org/docu
## Total Test Count
- **374 tests** across 10 test files (63 notifications schema/triggers + 19 notifications RLS)
- **382 tests** across 10 test files (71 notifications schema/triggers/NULL-actor + 19 notifications RLS)
- Comprehensive coverage of all database components
- Security-focused testing for RLS and permissions
- Full notification system coverage including triggers and RLS
- Full notification system coverage including triggers, RLS, and NULL actor (service role) scenarios

View file

@ -1,5 +1,5 @@
begin;
select plan(63); -- Total number of tests (54 original + 9 multiple notifications tests)
select plan(71); -- Total number of tests (54 original + 9 multiple notifications tests + 2 invite edge cases + 6 null actor tests)
-- ============================================================================
-- Table Existence Tests
@ -336,7 +336,77 @@ SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE entity_type = 'tablo_invites'
AND user_id = current_setting('test.user2_id')::uuid) > 0,
'Notification should be created when user is invited'
'Notification should be created when existing user is invited'
);
-- Test: No notification for non-existent user
DO $$
DECLARE
count_before bigint;
count_after bigint;
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
SELECT COUNT(*) INTO count_before FROM public.notifications;
-- Invite a non-existent user
INSERT INTO public.tablo_invites (tablo_id, invited_email, invited_by, invite_token, is_pending)
VALUES (
current_setting('test.tablo_id'),
'nonexistent@example.com',
current_setting('test.user1_id')::uuid,
'test-token-' || gen_random_uuid()::text,
true
);
SELECT COUNT(*) INTO count_after FROM public.notifications;
PERFORM set_config('test.nonexistent_notif_diff', (count_after - count_before)::text, true);
END $$;
SELECT is(
current_setting('test.nonexistent_notif_diff', true)::integer,
0,
'No notification should be created when inviting non-existent user'
);
-- Test: Case-insensitive email matching
DO $$
DECLARE
count_before bigint;
count_after bigint;
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
-- Count notifications for user2 before
SELECT COUNT(*) INTO count_before
FROM public.notifications
WHERE entity_type = 'tablo_invites'
AND user_id = current_setting('test.user2_id')::uuid;
-- Invite with uppercase email to test case-insensitivity
INSERT INTO public.tablo_invites (tablo_id, invited_email, invited_by, invite_token, is_pending)
VALUES (
current_setting('test.tablo_id'),
UPPER('notiftest2_' || current_setting('test.user2_id') || '@test.com'),
current_setting('test.user1_id')::uuid,
'test-token-uppercase-' || gen_random_uuid()::text,
true
);
-- Count after
SELECT COUNT(*) INTO count_after
FROM public.notifications
WHERE entity_type = 'tablo_invites'
AND user_id = current_setting('test.user2_id')::uuid;
PERFORM set_config('test.case_insensitive_notif_diff', (count_after - count_before)::text, true);
END $$;
SELECT is(
current_setting('test.case_insensitive_notif_diff', true)::integer,
1,
'Notification should be created with case-insensitive email matching'
);
-- ============================================================================
@ -595,5 +665,200 @@ SELECT is(
'Soft delete should not create additional notifications'
);
-- ============================================================================
-- NULL Actor Tests (Service Role / API Operations)
-- ============================================================================
-- Test: Tablo update with NULL actor should notify all collaborators
DO $$
DECLARE
notif_count_before bigint;
notif_count_after bigint;
BEGIN
-- Clear auth context (simulates service role call with no auth.uid())
PERFORM set_config('request.jwt.claims', NULL, true);
-- Count notifications before update
SELECT COUNT(*) INTO notif_count_before FROM public.notifications;
-- Update tablo without actor context
UPDATE public.tablos
SET name = 'Updated by Service Role'
WHERE id = current_setting('test.tablo_id');
-- Count notifications after update
SELECT COUNT(*) INTO notif_count_after FROM public.notifications;
PERFORM set_config('test.null_actor_notif_count', (notif_count_after - notif_count_before)::text, true);
END $$;
SELECT is(
current_setting('test.null_actor_notif_count', true)::integer,
5,
'With NULL actor, all collaborators (including owner) should be notified on tablo update (5 total: user1-5)'
);
-- Test: Task creation with NULL actor
DO $$
DECLARE
notif_count_before bigint;
notif_count_after bigint;
new_task_id text;
BEGIN
PERFORM set_config('request.jwt.claims', NULL, true);
SELECT COUNT(*) INTO notif_count_before FROM public.notifications;
-- Create task without actor context
INSERT INTO public.tasks (tablo_id, title, description, status, position)
VALUES (current_setting('test.tablo_id'), 'Service Role Task', 'Created via service role', 'todo', 2)
RETURNING id INTO new_task_id;
SELECT COUNT(*) INTO notif_count_after FROM public.notifications;
PERFORM set_config('test.null_actor_task_notif_count', (notif_count_after - notif_count_before)::text, true);
PERFORM set_config('test.null_actor_task_id', new_task_id, true);
END $$;
SELECT is(
current_setting('test.null_actor_task_notif_count', true)::integer,
5,
'With NULL actor, all tablo members should be notified on task creation (owner + 4 collaborators)'
);
-- Test: Tablo access grant with NULL actor
DO $$
DECLARE
test_user6_id uuid := gen_random_uuid();
notif_count_before bigint;
notif_count_after bigint;
BEGIN
-- Insert test user6
INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, created_at, updated_at)
VALUES
(test_user6_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'notiftest6_' || test_user6_id::text || '@test.com', 'encrypted', now(), now(), now())
ON CONFLICT DO NOTHING;
INSERT INTO public.profiles (id, email, first_name, last_name, short_user_id)
VALUES
(test_user6_id, 'notiftest6_' || test_user6_id::text || '@test.com', 'NotifTest', 'User6', substring(test_user6_id::text from 1 for 8))
ON CONFLICT DO NOTHING;
PERFORM set_config('test.user6_id', test_user6_id::text, true);
PERFORM set_config('request.jwt.claims', NULL, true);
SELECT COUNT(*) INTO notif_count_before
FROM public.notifications
WHERE user_id = test_user6_id;
-- Grant access without actor context
INSERT INTO public.tablo_access (tablo_id, user_id, granted_by, is_active, is_admin)
VALUES (current_setting('test.tablo_id'), test_user6_id, current_setting('test.user1_id')::uuid, true, false);
SELECT COUNT(*) INTO notif_count_after
FROM public.notifications
WHERE user_id = test_user6_id;
PERFORM set_config('test.null_actor_access_notif_diff', (notif_count_after - notif_count_before)::text, true);
END $$;
SELECT is(
current_setting('test.null_actor_access_notif_diff', true)::integer,
1,
'With NULL actor, user should still be notified when granted tablo access'
);
-- Test: Tablo invite with NULL actor (simulating API invite)
DO $$
DECLARE
notif_count_before bigint;
notif_count_after bigint;
BEGIN
PERFORM set_config('request.jwt.claims', NULL, true);
-- Count notifications for user6 before invite
SELECT COUNT(*) INTO notif_count_before
FROM public.notifications
WHERE user_id = current_setting('test.user6_id')::uuid
AND entity_type = 'tablo_invites';
-- Invite user6 without actor context (via service role)
INSERT INTO public.tablo_invites (tablo_id, invited_email, invited_by, invite_token, is_pending)
VALUES (
current_setting('test.tablo_id'),
'notiftest6_' || current_setting('test.user6_id') || '@test.com',
current_setting('test.user1_id')::uuid,
'test-token-null-actor-' || gen_random_uuid()::text,
true
);
-- Count after
SELECT COUNT(*) INTO notif_count_after
FROM public.notifications
WHERE user_id = current_setting('test.user6_id')::uuid
AND entity_type = 'tablo_invites';
PERFORM set_config('test.null_actor_invite_notif_diff', (notif_count_after - notif_count_before)::text, true);
END $$;
SELECT is(
current_setting('test.null_actor_invite_notif_diff', true)::integer,
1,
'With NULL actor, invited user should still receive notification (API-initiated invite)'
);
-- Test: Event creation with NULL actor
DO $$
DECLARE
notif_count_before bigint;
notif_count_after bigint;
new_event_id text;
BEGIN
PERFORM set_config('request.jwt.claims', NULL, true);
SELECT COUNT(*) INTO notif_count_before FROM public.notifications;
-- Create event without actor context
INSERT INTO public.events (tablo_id, title, description, start_date, start_time, created_by)
VALUES (current_setting('test.tablo_id'), 'Service Role Event', 'Event via service role', CURRENT_DATE, '14:00', current_setting('test.user1_id')::uuid)
RETURNING id INTO new_event_id;
SELECT COUNT(*) INTO notif_count_after FROM public.notifications;
PERFORM set_config('test.null_actor_event_notif_count', (notif_count_after - notif_count_before)::text, true);
END $$;
SELECT is(
current_setting('test.null_actor_event_notif_count', true)::integer,
6,
'With NULL actor, all tablo members should be notified on event creation (owner + 5 collaborators including user6)'
);
-- Test: Note update with NULL actor
DO $$
DECLARE
notif_count_before bigint;
notif_count_after bigint;
BEGIN
PERFORM set_config('request.jwt.claims', NULL, true);
SELECT COUNT(*) INTO notif_count_before FROM public.notifications;
-- Update note without actor context
UPDATE public.notes
SET content = 'Updated by service role'
WHERE id = current_setting('test.note_id');
SELECT COUNT(*) INTO notif_count_after FROM public.notifications;
PERFORM set_config('test.null_actor_note_notif_count', (notif_count_after - notif_count_before)::text, true);
END $$;
SELECT is(
current_setting('test.null_actor_note_notif_count', true)::integer,
2,
'With NULL actor, note owner (user1) and collaborator (user2) should both be notified on note update'
);
select * from finish();
rollback;