Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 19 additions & 21 deletions apps/app/src/actions/organization/accept-invitation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
'use server';

import { createTrainingVideoEntries } from '@/lib/db/employee';
import { auth } from '@/utils/auth';
import { db } from '@db';
import { revalidatePath, revalidateTag } from 'next/cache';
import { headers } from 'next/headers';
import { z } from 'zod';
import { authActionClientWithoutOrg } from '../safe-action';
import type { ActionResponse } from '../types';
Expand Down Expand Up @@ -72,32 +74,32 @@ export const completeInvitation = authActionClientWithoutOrg
});

if (existingMembership) {
if (ctx.session.activeOrganizationId !== invitation.organizationId) {
await db.session.update({
where: { id: ctx.session.id },
// Reactivate member before setting active org, since better-auth
// validates membership status when setting the active organization.
if (existingMembership.deactivated) {
await db.member.update({
where: { id: existingMembership.id },
data: {
activeOrganizationId: invitation.organizationId,
deactivated: false,
role: invitation.role,
},
});
}

if (ctx.session.activeOrganizationId !== invitation.organizationId) {
await auth.api.setActiveOrganization({
headers: await headers(),
body: { organizationId: invitation.organizationId },
});
}

await db.invitation.update({
where: { id: invitation.id },
data: {
status: 'accepted',
},
});

if (existingMembership.deactivated) {
await db.member.update({
where: { id: existingMembership.id },
data: {
deactivated: false,
role: invitation.role,
},
});
}

revalidatePath(`/${invitation.organization.id}`);
revalidateTag(`user_${user.id}`, 'max');

Expand Down Expand Up @@ -135,13 +137,9 @@ export const completeInvitation = authActionClientWithoutOrg
},
});

await db.session.update({
where: {
id: ctx.session.id,
},
data: {
activeOrganizationId: invitation.organizationId,
},
await auth.api.setActiveOrganization({
headers: await headers(),
body: { organizationId: invitation.organizationId },
});

revalidatePath(`/${invitation.organization.id}`);
Expand Down
40 changes: 27 additions & 13 deletions apps/app/src/app/(app)/[orgId]/layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock('@/lib/api-server', () => ({
}));
vi.mock('@/lib/permissions', () => ({
canAccessApp: vi.fn().mockReturnValue(true),
parseRolesString: vi.fn().mockReturnValue(['owner']),
}));
vi.mock('@/lib/permissions.server', () => ({
resolveUserPermissions: vi.fn().mockResolvedValue([]),
Expand All @@ -44,7 +45,7 @@ vi.mock('next/dynamic', () => ({
vi.mock('@aws-sdk/client-s3', () => ({ GetObjectCommand: vi.fn() }));
vi.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: vi.fn() }));

import { createMockSession, setupAuthMocks } from '@/test-utils/mocks/auth';
import { createMockSession, mockAuthApi, setupAuthMocks } from '@/test-utils/mocks/auth';
import { mockDb } from '@/test-utils/mocks/db';

const { default: Layout } = await import('./layout');
Expand All @@ -70,10 +71,10 @@ describe('Layout activeOrganizationId sync', () => {
deactivated: false,
});
mockDb.onboarding.findFirst.mockResolvedValue(null);
mockDb.session.update.mockResolvedValue({});
mockAuthApi.setActiveOrganization.mockResolvedValue({});
});

it('should update session directly in DB when session org differs from URL org', async () => {
it('should call setActiveOrganization via auth API when session org differs from URL org', async () => {
setupAuthMocks({
session: createMockSession({ id: sessionId, activeOrganizationId: 'org_other' }),
});
Expand All @@ -83,13 +84,13 @@ describe('Layout activeOrganizationId sync', () => {
params: Promise.resolve({ orgId: requestedOrgId }),
});

expect(mockDb.session.update).toHaveBeenCalledWith({
where: { id: sessionId },
data: { activeOrganizationId: requestedOrgId },
expect(mockAuthApi.setActiveOrganization).toHaveBeenCalledWith({
headers: expect.anything(),
body: { organizationId: requestedOrgId },
});
});

it('should update session when activeOrganizationId is null', async () => {
it('should call setActiveOrganization when activeOrganizationId is null', async () => {
setupAuthMocks({
session: createMockSession({ id: sessionId, activeOrganizationId: null }),
});
Expand All @@ -99,13 +100,13 @@ describe('Layout activeOrganizationId sync', () => {
params: Promise.resolve({ orgId: requestedOrgId }),
});

expect(mockDb.session.update).toHaveBeenCalledWith({
where: { id: sessionId },
data: { activeOrganizationId: requestedOrgId },
expect(mockAuthApi.setActiveOrganization).toHaveBeenCalledWith({
headers: expect.anything(),
body: { organizationId: requestedOrgId },
});
});

it('should NOT update session when session org matches URL org', async () => {
it('should NOT call setActiveOrganization when session org matches URL org', async () => {
setupAuthMocks({
session: createMockSession({ id: sessionId, activeOrganizationId: requestedOrgId }),
});
Expand All @@ -115,14 +116,27 @@ describe('Layout activeOrganizationId sync', () => {
params: Promise.resolve({ orgId: requestedOrgId }),
});

expect(mockAuthApi.setActiveOrganization).not.toHaveBeenCalled();
});

it('should not do a direct DB session update', async () => {
setupAuthMocks({
session: createMockSession({ id: sessionId, activeOrganizationId: 'org_other' }),
});

await Layout({
children: null,
params: Promise.resolve({ orgId: requestedOrgId }),
});

expect(mockDb.session.update).not.toHaveBeenCalled();
});

it('should continue rendering even if session update fails', async () => {
it('should continue rendering even if setActiveOrganization fails', async () => {
setupAuthMocks({
session: createMockSession({ id: sessionId, activeOrganizationId: 'org_other' }),
});
mockDb.session.update.mockRejectedValue(new Error('db update failed'));
mockAuthApi.setActiveOrganization.mockRejectedValue(new Error('API call failed'));

const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

Expand Down
11 changes: 4 additions & 7 deletions apps/app/src/app/(app)/[orgId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,13 @@ export default async function Layout({
}

// Sync activeOrganizationId if it doesn't match the URL's orgId.
// Direct DB update instead of HTTP call to avoid race conditions:
// Next.js renders layouts and pages in parallel, so child pages may call
// serverApi before an HTTP-based sync completes. A direct DB write is faster
// and membership has already been validated above.
// Uses better-auth's API so both server and client-side session state stay in sync.
const currentActiveOrgId = session.session.activeOrganizationId;
if (!currentActiveOrgId || currentActiveOrgId !== requestedOrgId) {
try {
await db.session.update({
where: { id: session.session.id },
data: { activeOrganizationId: requestedOrgId },
await auth.api.setActiveOrganization({
headers: requestHeaders,
body: { organizationId: requestedOrgId },
});
} catch (error) {
console.error('[Layout] Failed to sync activeOrganizationId:', error);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

// Mock the task API hooks
const mockRefreshAttachments = vi.fn();
const mockUploadAttachment = vi.fn();
const mockGetDownloadUrl = vi.fn();
const mockDeleteAttachment = vi.fn();

vi.mock('@/hooks/use-tasks-api', () => ({
useTaskAttachments: vi.fn(),
useTaskAttachmentActions: vi.fn(() => ({
uploadAttachment: mockUploadAttachment,
getDownloadUrl: mockGetDownloadUrl,
deleteAttachment: mockDeleteAttachment,
})),
}));

// Mock UI components to simplify rendering
vi.mock('@trycompai/ui/button', () => ({
Button: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<button {...props}>{children}</button>
),
}));
vi.mock('@trycompai/ui/dialog', () => ({
Dialog: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogDescription: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogTitle: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
}));

import { useTaskAttachments } from '@/hooks/use-tasks-api';
import { TaskBody } from './TaskBody';

const mockUseTaskAttachments = vi.mocked(useTaskAttachments);

describe('TaskBody', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should show upload dropzone even when attachments are loading', () => {
mockUseTaskAttachments.mockReturnValue({
data: undefined,
error: undefined,
isLoading: true,
mutate: mockRefreshAttachments,
isValidating: false,
});

render(<TaskBody taskId="tsk_123" />);

expect(screen.getByText('Drag and drop files here')).toBeInTheDocument();
});

it('should show upload dropzone when attachments data is undefined (SWR key is null)', () => {
mockUseTaskAttachments.mockReturnValue({
data: undefined,
error: undefined,
isLoading: false,
mutate: mockRefreshAttachments,
isValidating: false,
});

render(<TaskBody taskId="tsk_123" />);

expect(screen.getByText('Drag and drop files here')).toBeInTheDocument();
});

it('should show upload dropzone when attachments have loaded successfully', () => {
mockUseTaskAttachments.mockReturnValue({
data: { data: [], status: 200 } as never,
error: undefined,
isLoading: false,
mutate: mockRefreshAttachments,
isValidating: false,
});

render(<TaskBody taskId="tsk_123" />);

expect(screen.getByText('Drag and drop files here')).toBeInTheDocument();
});

it('should show upload dropzone when attachments fail to load', () => {
mockUseTaskAttachments.mockReturnValue({
data: undefined,
error: new Error('Failed to fetch'),
isLoading: false,
mutate: mockRefreshAttachments,
isValidating: false,
});

render(<TaskBody taskId="tsk_123" />);

expect(screen.getByText('Drag and drop files here')).toBeInTheDocument();
expect(screen.getByText('Failed to load attachments. Please try again.')).toBeInTheDocument();
});

it('should show loading skeletons while attachments are loading', () => {
mockUseTaskAttachments.mockReturnValue({
data: undefined,
error: undefined,
isLoading: true,
mutate: mockRefreshAttachments,
isValidating: false,
});

const { container } = render(<TaskBody taskId="tsk_123" />);

const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBe(3);
});

it('should not show loading skeletons when attachments have loaded', () => {
mockUseTaskAttachments.mockReturnValue({
data: { data: [], status: 200 } as never,
error: undefined,
isLoading: false,
mutate: mockRefreshAttachments,
isValidating: false,
});

const { container } = render(<TaskBody taskId="tsk_123" />);

const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBe(0);
});
});
Loading
Loading