Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: introduce branded types #1223

Merged
merged 12 commits into from
Jun 13, 2024
7 changes: 4 additions & 3 deletions src/__mocks__/partial-mocks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Hostname, Link } from '../types';
import type { Notification, Subject, User } from '../typesGitHub';
import Constants from '../utils/constants';
import { mockGitifyUser, mockToken } from './state-mocks';
Expand All @@ -9,7 +10,7 @@ export function partialMockNotification(
account: {
method: 'Personal Access Token',
platform: 'GitHub Cloud',
hostname: Constants.GITHUB_API_BASE_URL,
hostname: Constants.GITHUB_API_BASE_URL as Hostname,
token: mockToken,
user: mockGitifyUser,
},
Expand All @@ -22,8 +23,8 @@ export function partialMockNotification(
export function partialMockUser(login: string): User {
const mockUser: Partial<User> = {
login: login,
html_url: `https://github.com/${login}`,
avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
html_url: `https://github.com/${login}` as Link,
avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link,
type: 'User',
};

Expand Down
22 changes: 12 additions & 10 deletions src/__mocks__/state-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ import {
type AuthState,
type GitifyState,
type GitifyUser,
type Hostname,
type SettingsState,
Theme,
type Token,
} from '../types';
import type { EnterpriseAccount } from '../utils/auth/types';
import Constants from '../utils/constants';

export const mockEnterpriseAccounts: EnterpriseAccount[] = [
{
hostname: 'github.gitify.io',
token: '1234568790',
hostname: 'github.gitify.io' as Hostname,
token: '1234568790' as Token,
},
];

Expand All @@ -25,39 +27,39 @@ export const mockGitifyUser: GitifyUser = {
export const mockPersonalAccessTokenAccount: Account = {
platform: 'GitHub Cloud',
method: 'Personal Access Token',
token: 'token-123-456',
token: 'token-123-456' as Token,
hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname,
user: mockGitifyUser,
};

export const mockOAuthAccount: Account = {
platform: 'GitHub Enterprise Server',
method: 'OAuth App',
token: '1234568790',
hostname: 'github.gitify.io',
token: '1234568790' as Token,
hostname: 'github.gitify.io' as Hostname,
user: mockGitifyUser,
};

export const mockGitHubCloudAccount: Account = {
platform: 'GitHub Cloud',
method: 'Personal Access Token',
token: 'token-123-456',
token: 'token-123-456' as Token,
hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname,
user: mockGitifyUser,
};

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

export const mockGitHubAppAccount: Account = {
platform: 'GitHub Cloud',
method: 'GitHub App',
token: '987654321',
token: '987654321' as Token,
hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname,
user: mockGitifyUser,
};
Expand All @@ -66,7 +68,7 @@ export const mockAuth: AuthState = {
accounts: [mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount],
};

export const mockToken = 'token-123-456';
export const mockToken = 'token-123-456' as Token;

export const mockSettings: SettingsState = {
participating: false,
Expand Down
5 changes: 3 additions & 2 deletions src/components/NotificationRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
mockSettings,
} from '../__mocks__/state-mocks';
import { AppContext } from '../context/App';
import type { Link } from '../types';
import type { Milestone, UserType } from '../typesGitHub';
import { mockSingleNotification } from '../utils/api/__mocks__/response-mocks';
import * as comms from '../utils/comms';
Expand Down Expand Up @@ -473,9 +474,9 @@ describe('components/NotificationRow.tsx', () => {
...mockSingleNotification.subject,
user: {
login: 'some-user',
html_url: 'https://github.com/some-user',
html_url: 'https://github.com/some-user' as Link,
avatar_url:
'https://avatars.githubusercontent.com/u/123456789?v=4',
'https://avatars.githubusercontent.com/u/123456789?v=4' as Link,
type: 'User' as UserType,
},
reviews: null,
Expand Down
3 changes: 2 additions & 1 deletion src/components/Repository.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { mockGitHubCloudAccount } from '../__mocks__/state-mocks';
import { AppContext } from '../context/App';
import type { Link } from '../types';
import {
mockGitHubNotifications,
mockSingleNotification,
Expand Down Expand Up @@ -81,7 +82,7 @@ describe('components/Repository.tsx', () => {
});

it('should use default repository icon when avatar is not available', () => {
props.repoNotifications[0].repository.owner.avatar_url = '';
props.repoNotifications[0].repository.owner.avatar_url = '' as Link;

const tree = render(
<AppContext.Provider value={{}}>
Expand Down
3 changes: 2 additions & 1 deletion src/components/fields/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MarkGithubIcon } from '@primer/octicons-react';
import { fireEvent, render, screen } from '@testing-library/react';
import { shell } from 'electron';
import type { Link } from '../../types';
import { Button, type IButton } from './Button';

describe('components/fields/Button.tsx', () => {
Expand All @@ -27,7 +28,7 @@ describe('components/fields/Button.tsx', () => {
});

it('should render with url', () => {
render(<Button {...props} url="https://github.com" />);
render(<Button {...props} url={'https://github.com' as Link} />);

const buttonElement = screen.getByLabelText('button');

Expand Down
3 changes: 2 additions & 1 deletion src/components/fields/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Icon } from '@primer/octicons-react';
import type { FC } from 'react';
import type { Link } from '../../types';
import { cn } from '../../utils/cn';
import { openExternalLink } from '../../utils/comms';

Expand All @@ -9,7 +10,7 @@ export interface IButton {
className?: string;
icon?: Icon;
size?: number;
url?: string;
url?: Link;
onClick?: () => void;
disabled?: boolean;
type?: 'button' | 'submit';
Expand Down
6 changes: 3 additions & 3 deletions src/context/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { act, fireEvent, render, waitFor } from '@testing-library/react';
import { useContext } from 'react';
import { mockAuth, mockSettings } from '../__mocks__/state-mocks';
import { useNotifications } from '../hooks/useNotifications';
import type { AuthState, SettingsState } from '../types';
import type { AuthState, Hostname, SettingsState, Token } from '../types';
import { mockSingleNotification } from '../utils/api/__mocks__/response-mocks';
import * as apiRequests from '../utils/api/request';
import * as comms from '../utils/comms';
Expand Down Expand Up @@ -280,8 +280,8 @@ describe('context/App.tsx', () => {
type="button"
onClick={() =>
loginWithPersonalAccessToken({
hostname: 'github.com',
token: '123-456',
hostname: 'github.com' as Hostname,
token: '123-456' as Token,
})
}
>
Expand Down
8 changes: 4 additions & 4 deletions src/routes/LoginWithOAuthApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { shell } from 'electron';
import { MemoryRouter } from 'react-router-dom';
import { AppContext } from '../context/App';
import type { AuthState } from '../types';
import type { AuthState, ClientID, ClientSecret, Hostname } from '../types';
import { LoginWithOAuthApp, validate } from './LoginWithOAuthApp';

const mockNavigate = jest.fn();
Expand Down Expand Up @@ -64,9 +64,9 @@ describe('routes/LoginWithOAuthApp.tsx', () => {

values = {
...emptyValues,
hostname: 'hello',
clientId: '!@£INVALID-.1',
clientSecret: '!@£INVALID-.1',
hostname: 'hello' as Hostname,
clientId: '!@£INVALID-.1' as ClientID,
clientSecret: '!@£INVALID-.1' as ClientSecret,
};
expect(validate(values).hostname).toBe('Invalid hostname.');
expect(validate(values).clientId).toBe('Invalid client id.');
Expand Down
9 changes: 5 additions & 4 deletions src/routes/LoginWithOAuthApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom';
import { Button } from '../components/fields/Button';
import { FieldInput } from '../components/fields/FieldInput';
import { AppContext } from '../context/App';
import type { ClientID, ClientSecret, Hostname, Token } from '../types';
import type { LoginOAuthAppOptions } from '../utils/auth/types';
import {
getNewOAuthAppURL,
Expand All @@ -20,9 +21,9 @@ import {
import Constants from '../utils/constants';

interface IValues {
hostname?: string;
clientId?: string;
clientSecret?: string;
hostname?: Hostname;
clientId?: ClientID;
clientSecret?: ClientSecret;
}

interface IFormErrors {
Expand All @@ -48,7 +49,7 @@ export const validate = (values: IValues): IFormErrors => {

if (!values.clientSecret) {
errors.clientSecret = 'Required';
} else if (!isValidToken(values.clientSecret)) {
} else if (!isValidToken(values.clientSecret as unknown as Token)) {
errors.clientSecret = 'Invalid client secret.';
}

Expand Down
7 changes: 4 additions & 3 deletions src/routes/LoginWithPersonalAccessToken.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom';
import { Button } from '../components/fields/Button';
import { FieldInput } from '../components/fields/FieldInput';
import { AppContext } from '../context/App';
import type { Hostname, Token } from '../types';
import type { LoginPersonalAccessTokenOptions } from '../utils/auth/types';
import {
getNewTokenURL,
Expand All @@ -19,8 +20,8 @@ import {
import { Constants } from '../utils/constants';

interface IValues {
token?: string;
hostname?: string;
token?: Token;
hostname?: Hostname;
}

interface IFormErrors {
Expand Down Expand Up @@ -158,7 +159,7 @@ export const LoginWithPersonalAccessToken: FC = () => {
<Form
initialValues={{
hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname,
token: '',
token: '' as Token,
}}
onSubmit={login}
validate={validate}
Expand Down
26 changes: 22 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ import type {
PlatformType,
} from './utils/auth/types';

export type Status = 'loading' | 'success' | 'error';
declare const __brand: unique symbol;

type Brand<B> = { [__brand]: B };

export interface AuthState {
accounts: Account[];
/**
* @deprecated This attribute is deprecated and will be removed in a future release.
*/
token?: string;
token?: Token;
/**
* @deprecated This attribute is deprecated and will be removed in a future release.
*/
Expand All @@ -25,11 +27,27 @@ export interface AuthState {
user?: GitifyUser | null;
}

export type Branded<T, B> = T & Brand<B>;

export type AuthCode = Branded<string, 'AuthCode'>;

export type Token = Branded<string, 'Token'>;

export type ClientID = Branded<string, 'ClientID'>;

export type ClientSecret = Branded<string, 'ClientSecret'>;

export type Hostname = Branded<string, 'Hostname'>;

export type Link = Branded<string, 'WebUrl'>;

export type Status = 'loading' | 'success' | 'error';

export interface Account {
method: AuthMethod;
platform: PlatformType;
hostname: string;
token: string;
hostname: Hostname;
token: Token;
user: GitifyUser | null;
}

Expand Down
Loading