Skip to content
Open
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
16 changes: 16 additions & 0 deletions src/renderer/__mocks__/account-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Constants } from '../constants';
import type {
Account,
AccountNotifications,
Forge,
GitifyError,
Hostname,
Token,
Expand All @@ -11,7 +12,10 @@ import type {
import { getRecommendedScopeNames } from '../utils/auth/scopes';
import { mockGitifyUser } from './user-mocks';

const defaultForge: Forge = 'github';

export const mockGitHubAppAccount: Account = {
forge: defaultForge,
platform: 'GitHub Cloud',
method: 'GitHub App',
token: 'token-987654321' as Token,
Expand All @@ -30,6 +34,7 @@ export const mockPersonalAccessTokenAccount: Account = {
};

export const mockOAuthAccount: Account = {
forge: defaultForge,
platform: 'GitHub Enterprise Server',
method: 'OAuth App',
token: 'token-1234568790' as Token,
Expand All @@ -39,6 +44,7 @@ export const mockOAuthAccount: Account = {
};

export const mockGitHubCloudAccount: Account = {
forge: defaultForge,
platform: 'GitHub Cloud',
method: 'Personal Access Token',
token: 'token-123-456' as Token,
Expand All @@ -48,13 +54,23 @@ export const mockGitHubCloudAccount: Account = {
};

export const mockGitHubEnterpriseServerAccount: Account = {
forge: defaultForge,
platform: 'GitHub Enterprise Server',
method: 'Personal Access Token',
token: 'token-1234568790' as Token,
hostname: 'github.gitify.io' as Hostname,
user: mockGitifyUser,
};

export const mockGiteaAccount: Account = {
forge: 'gitea',
platform: 'Gitea',
method: 'Personal Access Token',
token: 'token-gitea' as Token,
hostname: 'gitea.example.com' as Hostname,
user: mockGitifyUser,
};

export function mockAccountWithError(error: GitifyError): AccountNotifications {
return {
account: mockGitHubCloudAccount,
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/__mocks__/notifications-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '../types';

import {
mockGiteaAccount,
mockGitHubAppAccount,
mockGitHubCloudAccount,
mockGitHubEnterpriseServerAccount,
Expand Down Expand Up @@ -218,6 +219,12 @@ export const mockGithubEnterpriseGitifyNotifications: GitifyNotification[] = [
export const mockGitifyNotification: GitifyNotification =
mockGitHubCloudGitifyNotifications[0];

/** Same shape as cloud notification, but bound to a Gitea account (for forge-specific tests). */
export const mockGiteaGitifyNotification: GitifyNotification = {
...mockGitifyNotification,
account: mockGiteaAccount,
};

export const mockMultipleAccountNotifications: AccountNotifications[] = [
{
account: mockGitHubCloudAccount,
Expand Down
18 changes: 17 additions & 1 deletion src/renderer/components/notifications/NotificationRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { renderWithProviders } from '../../__helpers__/test-utils';
import { mockGitifyNotification } from '../../__mocks__/notifications-mocks';
import {
mockGiteaGitifyNotification,
mockGitifyNotification,
} from '../../__mocks__/notifications-mocks';
import { mockSettings } from '../../__mocks__/state-mocks';

import { GroupBy } from '../../types';
Expand Down Expand Up @@ -260,5 +263,18 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => {

expect(unsubscribeNotificationMock).toHaveBeenCalledTimes(1);
});

it('should not show unsubscribe for Gitea notifications', () => {
const props: NotificationRowProps = {
notification: mockGiteaGitifyNotification,
isRepositoryAnimatingExit: false,
};

renderWithProviders(<NotificationRow {...props} />);

expect(
screen.queryByTestId('notification-unsubscribe-from-thread'),
).not.toBeInTheDocument();
});
});
});
6 changes: 5 additions & 1 deletion src/renderer/components/notifications/NotificationRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import { HoverGroup } from '../primitives/HoverGroup';

import { type GitifyNotification, Opacity, Size } from '../../types';

import { isMarkAsDoneFeatureSupported } from '../../utils/api/features';
import {
isIgnoreThreadSubscriptionSupported,
isMarkAsDoneFeatureSupported,
} from '../../utils/api/features';
import { isGroupByDate } from '../../utils/notifications/group';
import { shouldRemoveNotificationsFromState } from '../../utils/notifications/remove';
import { openNotification } from '../../utils/system/links';
Expand Down Expand Up @@ -154,6 +157,7 @@ export const NotificationRow: FC<NotificationRowProps> = ({

<HoverButton
action={actionUnsubscribeFromThread}
enabled={isIgnoreThreadSubscriptionSupported(notification.account)}
icon={BellSlashIcon}
label="Unsubscribe from thread"
testid="notification-unsubscribe-from-thread"
Expand Down
1 change: 1 addition & 0 deletions src/renderer/context/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ describe('renderer/context/App.tsx', () => {
'GitHub App',
'token',
'github.com',
'github',
);
});

Expand Down
31 changes: 27 additions & 4 deletions src/renderer/context/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
Account,
AccountNotifications,
AuthState,
Forge,
GitifyError,
GitifyNotification,
Hostname,
Expand Down Expand Up @@ -72,6 +73,15 @@ import {
import { zoomLevelToPercentage, zoomPercentageToLevel } from '../utils/ui/zoom';
import { defaultAuth, defaultSettings } from './defaults';

function normalizeAuthState(auth: AuthState): AuthState {
return {
accounts: auth.accounts.map((a) => ({
...a,
forge: a.forge ?? ('github' as Forge),
})),
};
}

export interface AppContextState {
auth: AuthState;
isLoggedIn: boolean;
Expand Down Expand Up @@ -130,7 +140,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {

const [auth, setAuth] = useState<AuthState>(
existingState.auth
? { ...defaultAuth, ...existingState.auth }
? { ...defaultAuth, ...normalizeAuthState(existingState.auth) }
: defaultAuth,
);

Expand Down Expand Up @@ -470,7 +480,13 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
await removeAccountNotifications(existingAccount);
}

const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname);
const updatedAuth = await addAccount(
auth,
'GitHub App',
token,
hostname,
'github',
);

persistAuth(updatedAuth);
await fetchNotifications({ auth: updatedAuth, settings });
Expand Down Expand Up @@ -504,6 +520,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
'OAuth App',
token,
authOptions.hostname,
'github',
);

persistAuth(updatedAuth);
Expand All @@ -522,15 +539,20 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
* Login with Personal Access Token (PAT).
*/
const loginWithPersonalAccessToken = useCallback(
async ({ token, hostname }: LoginPersonalAccessTokenOptions) => {
async ({ token, hostname, forge }: LoginPersonalAccessTokenOptions) => {
const resolvedForge: Forge = forge ?? 'github';
const encryptedToken = (await encryptValue(token)) as Token;
await fetchAuthenticatedUserDetails({
hostname,
token: encryptedToken,
forge: resolvedForge,
} as Account);

const existingAccount = auth.accounts.find(
(a) => a.hostname === hostname && a.method === 'Personal Access Token',
(a) =>
a.hostname === hostname &&
a.method === 'Personal Access Token' &&
(a.forge ?? 'github') === resolvedForge,
);
if (existingAccount) {
await removeAccountNotifications(existingAccount);
Expand All @@ -541,6 +563,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
'Personal Access Token',
token,
hostname,
resolvedForge,
);

persistAuth(updatedAuth);
Expand Down
72 changes: 65 additions & 7 deletions src/renderer/hooks/useNotifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
mockGitHubEnterpriseServerAccount,
} from '../__mocks__/account-mocks';
import {
mockGiteaGitifyNotification,
mockGitHubCloudGitifyNotifications,
mockGitifyNotification,
mockMultipleAccountNotifications,
Expand Down Expand Up @@ -529,25 +530,61 @@ describe('renderer/hooks/useNotifications.ts', () => {
expect(result.current.notifications.length).toBe(0);
});

it('should not mark as done when account does not support the feature', async () => {
// GitHub Enterprise Server without version doesn't support mark as done
it('should fall back to mark as read when mark-as-done is not supported', async () => {
const readSpy = vi
.spyOn(apiClient, 'markNotificationThreadAsRead')
.mockResolvedValue(undefined);
const doneSpy = vi
.spyOn(apiClient, 'markNotificationThreadAsDone')
.mockResolvedValue(undefined);

const mockEnterpriseNotification = {
...mockGitifyNotification,
account: mockGitHubEnterpriseServerAccount, // No version set
account: mockGitHubEnterpriseServerAccount,
};

const { result } = renderHook(() => useNotifications());

// The API should NOT be called when account doesn't support the feature
act(() => {
result.current.markNotificationsAsDone(mockState, [
mockEnterpriseNotification,
]);
});

// Status should remain 'success' (not change to 'loading' since we return early)
expect(result.current.status).toBe('success');
// No API calls should have been made - nock will fail if unexpected calls are made
await waitFor(() => {
expect(result.current.status).toBe('success');
});

expect(readSpy).toHaveBeenCalledWith(
mockGitHubEnterpriseServerAccount,
mockEnterpriseNotification.id,
);
expect(doneSpy).not.toHaveBeenCalled();
});

it('should fall back to mark as read for Gitea when mark-as-done is requested', async () => {
const readSpy = vi
.spyOn(apiClient, 'markNotificationThreadAsRead')
.mockResolvedValue(undefined);
const doneSpy = vi.spyOn(apiClient, 'markNotificationThreadAsDone');

const { result } = renderHook(() => useNotifications());

act(() => {
result.current.markNotificationsAsDone(mockState, [
mockGiteaGitifyNotification,
]);
});

await waitFor(() => {
expect(result.current.status).toBe('success');
});

expect(readSpy).toHaveBeenCalledWith(
mockGiteaGitifyNotification.account,
mockGiteaGitifyNotification.id,
);
expect(doneSpy).not.toHaveBeenCalled();
});

it('should mark notifications as done with failure', async () => {
Expand Down Expand Up @@ -599,6 +636,27 @@ describe('renderer/hooks/useNotifications.ts', () => {
expect(result.current.notifications.length).toBe(0);
});

it('should not call unsubscribe APIs when thread ignore is unsupported (Gitea)', async () => {
const ignoreSpy = vi.spyOn(
apiClient,
'ignoreNotificationThreadSubscription',
);
const readSpy = vi.spyOn(apiClient, 'markNotificationThreadAsRead');

const { result } = renderHook(() => useNotifications());

act(() => {
result.current.unsubscribeNotification(
mockState,
mockGiteaGitifyNotification,
);
});

expect(ignoreSpy).not.toHaveBeenCalled();
expect(readSpy).not.toHaveBeenCalled();
expect(result.current.status).toBe('success');
});

it('should unsubscribe from a notification with success - markAsDoneOnUnsubscribe = true', async () => {
vi.spyOn(
apiClient,
Expand Down
21 changes: 15 additions & 6 deletions src/renderer/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import {
markNotificationThreadAsDone,
markNotificationThreadAsRead,
} from '../utils/api/client';
import { isMarkAsDoneFeatureSupported } from '../utils/api/features';
import {
isIgnoreThreadSubscriptionSupported,
isMarkAsDoneFeatureSupported,
} from '../utils/api/features';
import { getAccountUUID } from '../utils/auth/utils';
import {
areAllAccountErrorsSame,
Expand Down Expand Up @@ -195,10 +198,12 @@ export const useNotifications = (): NotificationsState => {

const markNotificationsAsDone = useCallback(
async (state: GitifyState, doneNotifications: GitifyNotification[]) => {
if (
!state.settings ||
!isMarkAsDoneFeatureSupported(doneNotifications[0].account)
) {
if (!state.settings) {
return;
}

if (!isMarkAsDoneFeatureSupported(doneNotifications[0].account)) {
await markNotificationsAsRead(state, doneNotifications);
return;
}

Expand Down Expand Up @@ -229,7 +234,7 @@ export const useNotifications = (): NotificationsState => {

setStatus('success');
},
[notifications],
[notifications, markNotificationsAsRead],
);

const unsubscribeNotification = useCallback(
Expand All @@ -238,6 +243,10 @@ export const useNotifications = (): NotificationsState => {
return;
}

if (!isIgnoreThreadSubscriptionSupported(notification.account)) {
return;
}

setStatus('loading');

try {
Expand Down
Loading