Nest.jsにFirebase認証を使ったデータ取得をする

「Nest.jsをバックで使っている時にFirebase認証をどうやるの？」とふと思ったので自分なりに学んで実装した記事になります

# 構成
```zsh
.
├── Makefile
├── backend
│   ├── README.md
│   ├── nest-cli.json
│   ├── package-lock.json
│   ├── package.json
│   ├── src
│   │   ├── app.controller.spec.ts
│   │   ├── app.controller.ts
│   │   ├── app.module.ts
│   │   ├── app.service.ts
│   │   ├── auth
│   │   │   ├── auth.guard.spec.ts
│   │   │   └── auth.guard.ts
│   │   ├── firebase-admin
│   │   │   ├── firebase-admin.module.ts
│   │   │   ├── firebase-admin.service.spec.ts
│   │   │   └── firebase-admin.service.ts
│   │   ├── main.ts
│   │   ├── schema.gql
│   │   └── user
│   │       ├── dto
│   │       ├── entities
│   │       ├── user.module.ts
│   │       ├── user.resolver.spec.ts
│   │       ├── user.resolver.ts
│   │       ├── user.service.spec.ts
│   │       └── user.service.ts
│   ├── test
│   │   ├── app.e2e-spec.ts
│   │   └── jest-e2e.json
│   ├── tsconfig.build.json
│   └── tsconfig.json
├── docker
│   ├── backend
│   │   └── Dockerfile
│   └── frontend
│       └── Dockerfile
├── docker-compose.yml
└── frontend
    ├── README.md
    ├── codegen.ts
    ├── eslint.config.js
    ├── index.html
    ├── package-lock.json
    ├── package.json
    ├── public
    │   └── vite.svg
    ├── setupTests.ts
    ├── src
    │   ├── App.css
    │   ├── App.tsx
    │   ├── assets
    │   │   └── react.svg
    │   ├── auth
    │   │   ├── __tests__
    │   │   ├── firebase.ts
    │   │   ├── firebaseInit.ts
    │   │   └── useFirebase.ts
    │   ├── configs
    │   │   └── apolloClient.ts
    │   ├── gql
    │   │   ├── user.gen.ts
    │   │   └── user.gql
    │   ├── index.css
    │   ├── main.tsx
    │   ├── mocks
    │   │   ├── handlers.ts
    │   │   └── server.ts
    │   ├── page
    │   │   ├── __tests__
    │   │   ├── create.tsx
    │   │   ├── login.tsx
    │   │   ├── userWithLogin.tsx
    │   │   └── userWithoutLogin.tsx
    │   ├── providers
    │   │   └── apolloProvider.tsx
    │   ├── router
    │   │   └── router.tsx
    │   ├── types
    │   │   └── graphql.gen.ts
    │   └── vite-env.d.ts
    ├── tsconfig.app.json
    ├── tsconfig.json
    ├── tsconfig.node.json
    └── vite.config.ts
```

## バックエンド側
タイトルにもある通りNestjsになります

https://nestjs.com/

## フロントエンド側
サクッと構築したいのでReactになります
FireabaseAuthでログイン機能を実装・バックエンドとやり取りをする形になります

https://ja.react.dev/

## docker
dockerは本題とは違うため簡単な形で構成しています

https://www.docker.com/ja-jp/

# 環境構築

## docker
```zsh:zsh
mkdir nestFirebase
cd nestFirebase
```

初めに`docker-compose.yml`を作成していきます

```yml:docker-compose.yml
version: '3.8'
services:
  frontend:
    build:
      context: ./frontend
      dockerfile: ../docker/frontend/Dockerfile
    ports:
      - ${FRONTEND_PORT}:${FRONTEND_PORT}
    environment:
      VITE_BACKEND_URI: ${BACKEND_URI}
      VITE_FIREBASE_API_KEY: ${FIREBASE_API_KEY}
      VITE_FIREBASE_AUTH_DOMAIN: ${FIREBASE_AUTH_DOMAIN}
      VITE_FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID}
      VITE_FIREBASE_STORAGE_BUCKET: ${FIREBASE_STORAGE_BUCKET}
      VITE_FIREBASE_MESSAGING_SENDER_ID: ${FIREBASE_MESSAGING_SENDER_ID}
      VITE_FIREBASE_APP_ID: ${FIREBASE_APP_ID}
    volumes:
      - ./frontend:/app
    depends_on:
      - backend

  backend:
    build:
      context: ./backend
      dockerfile: ../docker/backend/Dockerfile
    ports:
      - ${BACKEND_PORT}:${BACKEND_PORT}
    depends_on:
      - postgres
    volumes:
      - ./backend:/app
    environment:
      POSTGRES_HOST: ${POSTGRES_HOST}
      POSTGRES_PORT: ${POSTGRES_PORT}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      PORT: ${BACKEND_PORT}
      SCHEMA: ${POSTGRES_SCHEMA}
      FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID}
      FIREBASE_CLIENT_EMAIL: ${FIREBASE_CLIENT_EMAIL}
      FIREBASE_PRIVATE_KEY: ${FIREBASE_PRIVATE_KEY}
    networks:
      - backend-network

  postgres:
    image: postgres:13
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - backend-network

volumes:
  postgres_data:

networks:
  backend-network:
    driver: bridge
```

.envを整えていきます
PostgresSQL・Portは個人開発用設定のため載せています

```.env
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=mydb
POSTGRES_SCHEMA=public

BACKEND_PORT=4000

FRONTEND_PORT=3000
BACKEND_URI=http://localhost:4000/graphql

FIREBASE_API_KEY=
FIREBASE_AUTH_DOMAIN=
FIREBASE_PROJECT_ID=
FIREBASE_STORAGE_BUCKET=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=
FIREBASE_CLIENT_EMAIL=
FIREBASE_PRIVATE_KEY=
```

* FIREBASE_API_KEY=
* FIREBASE_AUTH_DOMAIN=
* FIREBASE_PROJECT_ID=
* FIREBASE_STORAGE_BUCKET=
* FIREBASE_MESSAGING_SENDER_ID=
* FIREBASE_APP_ID=

上記6つはFirebaseのアプリに表示されているConfigを記載してください

![CleanShot 2024-11-22 at 07.24.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/96df8211-28a7-286a-90b4-51d452de28e1.png)

* FIREBASE_CLIENT_EMAIL=
* FIREBASE_PRIVATE_KEY=

上記２つは`プロジェクトの設定 ＞ サービスアカウント`移動するとFirebase Admin SDKの欄があるので「新しい秘密鍵を生成」をクリックしてダウンロードします
ダウンロード出来たら開いて
* `client_email`は`FIREBASE_CLIENT_EMAIL`に記載
* `private_key`は`FIREBASE_PRIVATE_KEY`に記載
してください

![CleanShot 2024-11-22 at 07.40.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/99f29f26-c7c1-2cca-77b0-ba3372e42ebe.png)

backendとfrontendの`Dockerfile`を整えていきます
今回はバックエンド用・フロントエンド用の二種類になります

### バックエンド用Dockerfile
```zsh:zsh
mkdir docker
cd docker
mkdir backend
cd backend
touch Dockerfile
```

```Dockerfile:docker/backend/Dockerfile
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
EXPOSE 4000
CMD ["npm", "run", "start:dev"]
```

### フロントエンド用Dockerfile
```zsh:zsh
mkdir docker
cd docker
mkdir frontend
cd frontend
touch Dockerfile
```

```Dockerfile:docker/frontend/Dockefile
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
EXPOSE 3000
CMD ["npm", "run", "dev"]
```

## バックエンド構築

```zsh:zsh
npx nest new backend
```
今回は`Which package manager would you ❤️  to use?`を`npm`で選択していますが、ご自身の使いやすいものを選択してください
npm以外を選択した場合は今後のnpmで書かれている箇所を読み替えてください

```zsh:zsh
⚡  We will scaffold your app in a few seconds..

? Which package manager would you ❤️  to use? npm
CREATE backend/.eslintrc.js (663 bytes)
CREATE backend/.prettierrc (51 bytes)
CREATE backend/README.md (5205 bytes)
CREATE backend/nest-cli.json (171 bytes)
CREATE backend/package.json (1943 bytes)
CREATE backend/tsconfig.build.json (97 bytes)
CREATE backend/tsconfig.json (546 bytes)
CREATE backend/src/app.controller.ts (274 bytes)
CREATE backend/src/app.module.ts (249 bytes)
CREATE backend/src/app.service.ts (142 bytes)
CREATE backend/src/main.ts (228 bytes)
CREATE backend/src/app.controller.spec.ts (617 bytes)
CREATE backend/test/jest-e2e.json (183 bytes)
CREATE backend/test/app.e2e-spec.ts (630 bytes)

✔ Installation in progress... ☕

🚀  Successfully created project backend
👉  Get started with the following commands:

$ cd backend
$ npm run start

                                         
                          Thanks for installing Nest 🙏
                 Please consider donating to our open collective
                        to help us maintain this package.
                                         
                                         
               🍷  Donate: https://opencollective.com/nest
```

## フロントエンド構築
```zsh:zsh
npm create vite@latest frontend -- --template react-swc-ts
```

portをDockerfileに合わせるため`vite.config.ts`を変更していきます
```diff_typescript:frontend/vite.config.ts
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
+  server: {
+    host: "0.0.0.0",
+    port: 3000,
+  },
});
```

## 実行

ここまで来たらコンテナが問題なく作成・起動するか確認をしてみます


```zsh:zsh
docker compose up -d --build
```

下記にアクセスして問題なく表示されたら一先ず完了になります
### バックエンド

http://localhost:4000

### フロントエンド

http://localhost:3000

# バックエンド実装

## インストール
バックエンド側で必要なライブラリをインストールしていきます

```zsh:zsh
npm install @apollo/server @nestjs/apollo @nestjs/graphql graphql
npm install --save @nestjs/config firebase-admin typeorm class-validator class-transformer @nestjs/typeorm @nestjs/config 
```

## CORSの設定
バックエンドだけであれば問題ないのですが、今回はフロントエンドも実装するのでCORS設定が必要です

```diff_typescript:backend/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

+  app.enableCors({
+    origin: 'http://localhost:3000',
+    methods: 'GET, POST, PUT, DELETE',
+    allowedHeaders: 'Content-Type, Authorization',
+  });

  await app.listen(process.env.PORT);
}
bootstrap();
```

## app.module.tsを整える
必要な設定を記載しています

```ts:backend/src/app.module.ts
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { FirebaseAdminModule } from './firebase-admin/firebase-admin.module';
import { FirebaseAdminService } from './firebase-admin/firebase-admin.service';
import { UserModule } from './user/user.module';

const configService = new ConfigService();

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
      playground: true,
      installSubscriptionHandlers: true,
      debug: true,
      sortSchema: true,
    }),
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: configService.get('POSTGRES_HOST'),
      port: configService.get('POSTGRES_PORT'),
      username: configService.get('POSTGRES_USER'),
      password: configService.get('POSTGRES_PASSWORD'),
      database: configService.get('POSTGRES_DB'),
      schema: configService.get('POSTGRES_SCHEMA'),
      entities: [__dirname + '/**/*.entity{.ts,.js}'],
      synchronize: true,
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
```

## 認証機能を整える
今回一番大事な箇所になります
serviceとguardを使用して認証機能を実装します

### 1. service作成
serviceを作成していきます

```zsh:zsh
docker compose exec backend npx nest g s firebaseAdmin
```
問題なければ`backend/src/firebase-admin/firebase-admin.service.ts`が作成されているのでコードを書いていきます
`admin.auth().verifyIdToken(token)`でフロントエンドからjwtが渡ってきて検証する形になります
カスタムクレームを設定していればより厳密な検証が行えます

```ts:backend/src/firebase-admin/firebase-admin.service.ts
import {
  Injectable,
  OnModuleInit,
  UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
import { ServiceAccount } from 'firebase-admin';

@Injectable()
export class FirebaseAdminService implements OnModuleInit {
  onModuleInit() {
    const configService = new ConfigService();
    if (admin.apps.length === 0) {
      admin.initializeApp({
        credential: admin.credential.cert({
          projectId: configService.get('FIREBASE_PROJECT_ID'),
          clientEmail: configService.get('FIREBASE_CLIENT_EMAIL'),
          privateKey: configService
            .get('FIREBASE_PRIVATE_KEY')
            .replace(/\\n/g, '\n'),
        } as ServiceAccount),
      });
    }
  }

  async verifyToken(token: string) {
    try {
      return await admin.auth().verifyIdToken(token);
    } catch (error) {
      throw new UnauthorizedException('Invalid token', error);
    }
  }
}
```

<details><summary>テスト</summary>

```ts:backend/src/firebase-admin/firebase-admin.service.spec.ts
import { UnauthorizedException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import * as admin from 'firebase-admin';
import { FirebaseAdminService } from './firebase-admin.service';

describe('FirebaseAdminService', () => {
  let service: FirebaseAdminService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [FirebaseAdminService],
    }).compile();

    service = module.get<FirebaseAdminService>(FirebaseAdminService);

    jest.spyOn(admin, 'auth').mockReturnValue({
      verifyIdToken: jest.fn(),
    } as any);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('verifyToken', () => {
    it('有効なトークを検証する', async () => {
      const mockToken = 'valid_token';
      const mockDecodedToken = { uid: 'user123' };

      (admin.auth().verifyIdToken as jest.Mock).mockResolvedValueOnce(
        mockDecodedToken,
      );

      const result = await service.verifyToken(mockToken);
      expect(result).toEqual(mockDecodedToken);
    });

    it('無効なトークンでUnauthorizedExceptionを投げる', async () => {
      const mockToken = 'invalid_token';

      (admin.auth().verifyIdToken as jest.Mock).mockRejectedValueOnce(
        new Error('Invalid token'),
      );

      await expect(service.verifyToken(mockToken)).rejects.toThrow(
        UnauthorizedException,
      );
    });
  });
});
```
</details>

### 2. module作成
moduleを作成していきます

```zsh:zsh
docker compose exec backend npx nest g mo firebaseAdmin
```

問題なければ`backend/src/firebase-admin/firebase-admin.module.ts`が作成されるのでコードを書いていきます
前述したserviceを呼んであげるだけになります

```ts:backend/src/firebase-admin/firebase-admin.module.ts
import { Global, Module } from '@nestjs/common';
import { FirebaseAdminService } from './firebase-admin.service';

@Global()
@Module({
  providers: [FirebaseAdminService],
  exports: [FirebaseAdminService],
})
export class FirebaseAdminModule {}
```


### 3. guard作成
guardを作成していきます

```zsh:zsh
docker compose exec backend npx nest g gu auth
```

問題なければ`backend/src/auth/auth.guard.ts`が作成されるのでコードを書いていきます
headerの中身にある`authorization`からjwtを取得、serviceにて実装した`verifyToken`を使って認証処理を行っています

> req.uuid = decodedToken.uid;
> req.email = decodedToken.email;

と記載しておけば処理内でuuidとemailを取得することが出来ます
uuidとemailの取得をバックエンドのみで簡潔することが出来ます

```ts:backend/src/auth/auth.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { FirebaseAdminService } from '../firebase-admin/firebase-admin.service';

export type AuthContext = {
  req: Record<'uuid' | 'email', string>;
};

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private readonly firebaseAdminService: FirebaseAdminService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const ctx = GqlExecutionContext.create(context);
    const req = ctx.getContext().req;

    const authHeader = req.headers['authorization'];
    const token = authHeader?.split(' ')[1];

    if (!token) {
      throw new UnauthorizedException('Token not found');
    }

    try {
      const decodedToken = await this.firebaseAdminService.verifyToken(token);
      req.uuid = decodedToken.uid;
      req.email = decodedToken.email;

      if (!req.uuid || !req.email) {
        throw new UnauthorizedException('Invalid token payload');
      }

      return true;
    } catch (error) {
      console.error('Token verification failed:', error);
      throw new UnauthorizedException('Invalid token');
    }
  }
}
```

<details><summary>テスト</summary>

```ts:backend/src/auth/auth.guard.spec.ts
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { FirebaseAdminService } from '../firebase-admin/firebase-admin.service';
import { AuthGuard } from './auth.guard';

describe('AuthGuard', () => {
  let authGuard: AuthGuard;
  let mockFirebaseAdminService: Partial<FirebaseAdminService>;

  beforeEach(() => {
    mockFirebaseAdminService = {
      verifyToken: jest.fn(),
    };
    authGuard = new AuthGuard(mockFirebaseAdminService as FirebaseAdminService);
    jest.spyOn(console, 'error').mockImplementation(() => {});
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('定義されていること', () => {
    expect(authGuard).toBeDefined();
  });

  it('トークンが有効であればアクセスを許可すること', async () => {
    const mockToken = 'valid-token';
    const mockDecodedToken = { uid: '123', email: 'test@example.com' };
    (mockFirebaseAdminService.verifyToken as jest.Mock).mockResolvedValue(
      mockDecodedToken,
    );

    const mockReq = {
      headers: {
        authorization: `Bearer ${mockToken}`,
      },
      uuid: '',
      email: '',
    };

    const mockContext = {
      getContext: () => ({ req: mockReq }),
      getType: () => 'graphql',
    } as unknown as ExecutionContext;

    jest
      .spyOn(GqlExecutionContext, 'create')
      .mockReturnValue(mockContext as any);

    const result = await authGuard.canActivate(mockContext);
    expect(result).toBe(true);
    expect(mockReq.uuid).toBe(mockDecodedToken.uid);
    expect(mockReq.email).toBe(mockDecodedToken.email);
  });

  it('トークンが無効であればUnauthorizedExceptionをスローすること', async () => {
    const mockToken = 'invalid-token';
    (mockFirebaseAdminService.verifyToken as jest.Mock).mockRejectedValue(
      new Error('Invalid token'),
    );

    const mockReq = {
      headers: {
        authorization: `Bearer ${mockToken}`,
      },
    };

    const mockContext = {
      getContext: () => ({ req: mockReq }),
      getType: () => 'graphql',
    } as unknown as ExecutionContext;

    jest
      .spyOn(GqlExecutionContext, 'create')
      .mockReturnValue(mockContext as any);

    await expect(authGuard.canActivate(mockContext)).rejects.toThrow(
      UnauthorizedException,
    );
  });

  it('トークンが提供されていなければUnauthorizedExceptionをスローすること', async () => {
    const mockReq = {
      headers: {},
    };

    const mockContext = {
      getContext: () => ({ req: mockReq }),
      getType: () => 'graphql',
    } as unknown as ExecutionContext;

    jest
      .spyOn(GqlExecutionContext, 'create')
      .mockReturnValue(mockContext as any);

    await expect(authGuard.canActivate(mockContext)).rejects.toThrow(
      UnauthorizedException,
    );
  });

  it('トークンのペイロードにuidまたはemailが含まれていない場合、UnauthorizedExceptionをスローすること', async () => {
    const mockToken = 'valid-token';
    const mockDecodedToken = { uid: '', email: '' };
    (mockFirebaseAdminService.verifyToken as jest.Mock).mockResolvedValue(
      mockDecodedToken,
    );

    const mockReq = {
      headers: {
        authorization: `Bearer ${mockToken}`,
      },
    };

    const mockContext = {
      getContext: () => ({ req: mockReq }),
      getType: () => 'graphql',
    } as unknown as ExecutionContext;

    jest
      .spyOn(GqlExecutionContext, 'create')
      .mockReturnValue(mockContext as any);

    await expect(authGuard.canActivate(mockContext)).rejects.toThrow(
      UnauthorizedException,
    );
  });
});
```
</details>

## Userテーブル
今回は簡単に実装するためにUserテーブルのみ作成していきます

### 1. entity作成
まずはテーブル構造に当たるentityを作成していきます
```zsh:zsh
mkdir -p backend/src/user/entities
touch backend/src/user/entities/user.entity.ts
```

作成出来たらコードを書いていきます
`uuid`・`name`・`email`の３つだけにしています

```ts:backend/src/user/entities/user.entity.ts
import { Field, ObjectType } from '@nestjs/graphql';
import { Column, Entity, PrimaryColumn } from 'typeorm';

@ObjectType()
@Entity()
export class User {
  @Field(() => String)
  @PrimaryColumn({ nullable: false, comment: 'FIrebase Auth UUID' })
  uuid: string;

  @Field()
  @Column({ nullable: false, comment: 'ユーザー名' })
  name: string;

  @Field()
  @Column({ nullable: false, comment: 'メールアドレス' })
  email: string;
}
```

### 2. DTO作成
DAO・DTOとは少し違ってRequest Payloadの型を定義したものだと考えてください
初めにdtoを入れておくディレクトリを作成します
```zsh:zsh
mkdir backend/src/user/dto
```

#### 2-1. 新規作成で使用するdto作成
初めにユーザーデータを新規作成する際に使用するdtoを作成していきます

* create-user-only-name.input
    * フロントエンドから送られているnameの型を定義します
* create-user.input.ts
    * resolverからserviceを呼ぶ際に渡す型を定義します

```zsh:zsh
touch backend/src/user/dto/create-user-only-name.input.ts
touch backend/src/user/dto/create-user.input.ts
```

```ts:create-user-only-name.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';

@InputType()
export class CreateUserOnlyNameInput {
  @Field()
  @IsNotEmpty()
  @IsString()
  name: string;
}
```

```ts:create-user.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';

@InputType()
export class CreateUserInput {
  @Field()
  @IsNotEmpty()
  @IsString()
  uuid: string;

  @Field()
  @IsNotEmpty()
  @IsString()
  name: string;

  @Field()
  @IsNotEmpty()
  @IsString()
  email: string;
}
```

#### 2-2. データ呼び出しに使用するdto作成

データ呼び出し時はFirebaseAuthのuuidを元にデータを絞り込んで検索出来るように型を定義しています

```zsh:zsh
touch backend/src/user/dto/find-one-by-uuid.input.ts
```

```ts:backend/src/user/dto/find-one-by-uuid.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';

@InputType()
export class FindOneByUuidInput {
  @Field()
  @IsNotEmpty()
  @IsString()
  uuid: string;
}
```

### 3. service作成

```zsh:zsh
docker compose exec backend npx nest g s user
```

問題なければ`backend/src/user/user.service.ts`が作成されるのでコードを書いていきます
dto作成時にあった、新規作成・データ呼び出し以外に全件呼び出しをする処理も実装していきます

```ts:backend/user/user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateUserInput } from './dto/create-user.input';
import { FindOneByUuidInput } from './dto/find-one-by-uuid.input';
import { User } from './entities/user.entity';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
  ) {}

  findAll(): Promise<User[]> {
    return this.userRepository.find();
  }

  findOneByUUID(findOneByUuidInput: FindOneByUuidInput): Promise<User> {
    return this.userRepository.findOneBy(findOneByUuidInput);
  }

  create(createUserInput: CreateUserInput): Promise<User> {
    const user = this.userRepository.create(createUserInput);
    return this.userRepository.save(user);
  }
}
```

<details><summary>テスト</summary>

```ts:backend/src/user/auth.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { UserService } from './user.service';

describe('UserService', () => {
  let service: UserService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserService,
        {
          provide: getRepositoryToken(User),
          useClass: Repository,
        },
      ],
    }).compile();

    service = module.get<UserService>(UserService);
  });

  it('定義されている', () => {
    expect(service).toBeDefined();
  });

  it('findAllでユーザーの配列が返される', async () => {
    const result = [new User()];
    jest.spyOn(service, 'findAll').mockResolvedValue(result);

    expect(await service.findAll()).toBe(result);
  });

  it('findOneByUUIDでユーザーが返される', async () => {
    const result = new User();
    jest.spyOn(service, 'findOneByUUID').mockResolvedValue(result);

    expect(await service.findOneByUUID({ uuid: 'some-uuid' })).toBe(result);
  });

  it('createで新規ユーザーが返される', async () => {
    const result = new User();
    jest.spyOn(service, 'create').mockResolvedValue(result);

    expect(
      await service.create({
        uuid: 'some-uuid',
        email: 'john.doe@example.com',
        name: 'John Doe',
      }),
    ).toBe(result);
  });
});
```
</details>

### 4. resolver作成

```zsh:zsh
docker compose exec backend npx nest g r user
```

問題なければ`backend/src/user/user.resolver.ts`が作成されるのでコードを書いていきます
dtoの他に前述したAuthGuardを呼び出してあげます

`@UseGuards(AuthGuard)`と呼び出してあげると認証処理が走り、失敗したらデータ取得出来ずエラーになります

`@Context() context: AuthContext`と呼ぶと処理内でuuidとemailの取得が出来ます

```ts
import { UseGuards } from '@nestjs/common';
import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
import { AuthContext, AuthGuard } from '../auth/auth.guard';
import { CreateUserOnlyNameInput } from './dto/create-user-only-name.input';
import { CreateUserInput } from './dto/create-user.input';
import { User } from './entities/user.entity';
import { UserService } from './user.service';

@Resolver(() => User)
export class UserResolver {
  constructor(private readonly userService: UserService) {}

  @Query(() => [User], { name: 'users' })
  findAll() {
    return this.userService.findAll();
  }

  @Query(() => User, { name: 'user' })
  @UseGuards(AuthGuard)
  findOne(@Context() context: AuthContext) {
    const { uuid } = context.req;

    return this.userService.findOneByUUID({ uuid });
  }

  @Mutation(() => User)
  @UseGuards(AuthGuard)
  createUser(
    @Args('createUserOnlyNameInput')
    createUserOnlyNameInput: CreateUserOnlyNameInput,
    @Context() context: AuthContext,
  ) {
    const { uuid, email } = context.req;
    const userWithUUID: CreateUserInput = {
      ...createUserOnlyNameInput,
      uuid,
      email,
    };
    return this.userService.create(userWithUUID);
  }
}
```

<details><summary>テスト</summary>

```ts:backend/src/user/auth.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { DecodedIdToken } from 'firebase-admin/lib/auth/token-verifier';
import { AuthGuard } from '../auth/auth.guard';
import { CreateUserOnlyNameInput } from './dto/create-user-only-name.input';
import { User } from './entities/user.entity';
import { UserResolver } from './user.resolver';
import { UserService } from './user.service';

describe('UserResolver', () => {
  let resolver: UserResolver;
  let userService: UserService;

  beforeEach(async () => {
    const mockAuthGuard = {
      canActivate: jest.fn(() => true),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserResolver,
        {
          provide: UserService,
          useValue: {
            findAll: jest.fn(),
            findOneByUUID: jest.fn(),
            create: jest.fn(),
          },
        },
      ],
    })
      .overrideGuard(AuthGuard)
      .useValue(mockAuthGuard)

      .compile();

    resolver = module.get<UserResolver>(UserResolver);
    userService = module.get<UserService>(UserService);
  });

  it('定義されているべき', () => {
    expect(resolver).toBeDefined();
  });

  describe('findAll', () => {
    it('ユーザーの配列を返すべき', async () => {
      const result = [new User()];
      jest.spyOn(userService, 'findAll').mockResolvedValue(result);
      expect(await resolver.findAll()).toBe(result);
    });
  });

  describe('findOne', () => {
    it('単一のユーザーを返すべき', async () => {
      const user = new User();
      const decodedIdToken: DecodedIdToken = {
        uid: 'test-uid',
        email: 'test@example.com',
      } as DecodedIdToken;
      jest.spyOn(userService, 'findOneByUUID').mockResolvedValue(user);
      expect(
        await resolver.findOne({
          req: { uuid: decodedIdToken.uid, email: decodedIdToken.email },
        }),
      ).toBe(user);
    });
  });

  describe('createUser', () => {
    it('ユーザーを作成して返すべき', async () => {
      const user = new User();
      const decodedIdToken: DecodedIdToken = {
        uid: 'test-uid',
        email: 'test@example.com',
      } as DecodedIdToken;
      const createUserOnlyNameInput: CreateUserOnlyNameInput = {
        name: 'Test User',
      };
      jest.spyOn(userService, 'create').mockResolvedValue(user);
      expect(
        await resolver.createUser(createUserOnlyNameInput, {
          req: { uuid: decodedIdToken.uid, email: decodedIdToken.email },
        }),
      ).toBe(user);
    });
  });
});
```
</details>

### 5. module作成
moduleを作成していきます

```zsh:zsh
docker compose exec backend npx nest g mo user
```

問題なければ`backend/src/user/user.module.ts`が作成されるのでコードを書いていきます
今まで作成してきたUser関連のファイルを呼んであげます

```ts:backend/src/user/user.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UserResolver } from './user.resolver';
import { UserService } from './user.service';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UserService, UserResolver],
})
export class UserModule {}
```

## app.module.tsを整える
ここまで来たら最後に`app.module.ts`を整えます
module類は自動で追加されています
ただ、serviceは追加されていないので追加してあげます

```diff_typescript:backend/src/app.module.ts
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { FirebaseAdminModule } from './firebase-admin/firebase-admin.module';
import { FirebaseAdminService } from './firebase-admin/firebase-admin.service';
import { UserModule } from './user/user.module';

const configService = new ConfigService();

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
      playground: true,
      installSubscriptionHandlers: true,
      debug: true,
      sortSchema: true,
    }),
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: configService.get('POSTGRES_HOST'),
      port: configService.get('POSTGRES_PORT'),
      username: configService.get('POSTGRES_USER'),
      password: configService.get('POSTGRES_PASSWORD'),
      database: configService.get('POSTGRES_DB'),
      schema: configService.get('POSTGRES_SCHEMA'),
      entities: [__dirname + '/**/*.entity{.ts,.js}'],
      synchronize: true,
    }),
    UserModule,
    FirebaseAdminModule,
  ],
  controllers: [AppController],
  providers: [
    AppService,
+    FirebaseAdminService,
  ],
})
export class AppModule {}
```

問題なければnestのログでエラーなく実行されているはずです
```zsh:zsh
docker compose logs -f backend
```

```zsh:docker compose log -f backend
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [NestFactory] Starting Nest application...
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [InstanceLoader] TypeOrmModule dependencies initialized +45ms
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [InstanceLoader] FirebaseAdminModule dependencies initialized +0ms
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [InstanceLoader] AppModule dependencies initialized +0ms
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [InstanceLoader] GraphQLSchemaBuilderModule dependencies initialized +0ms
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [InstanceLoader] GraphQLModule dependencies initialized +1ms
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [InstanceLoader] TypeOrmCoreModule dependencies initialized +50ms
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [InstanceLoader] UserModule dependencies initialized +1ms
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [RoutesResolver] AppController {/}: +3ms
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [RouterExplorer] Mapped {/, GET} route +1ms
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [GraphQLModule] Mapped {/graphql, POST} route +57ms
backend-1  | [Nest] 207  - 11/27/2024, 11:49:37 PM     LOG [NestApplication] Nest application successfully started +28ms
```

下記にアクセスして添付画像のplaygroundが表示されていればバックエンド側の実装完了になります

http://localhost:4000/graphql

![CleanShot 2024-11-30 at 00.37.55@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/0b128cd9-e1e8-974d-edb9-1ac2c4cf7c3f.png)


# フロントエンド実装
## インストール
```zsh:zsh
npm install --save @apollo/client firebase graphql graphql-request react-router-dom @types/react-dom
npm install --save-dev @graphql-codegen/cli @graphql-codegen/near-operation-file-preset @graphql-codegen/typescript @graphql-codegen/typescript-graphql-request @graphql-codegen/typescript-msw @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo @graphql-codegen/typescript-resolvers @testing-library/dom @testing-library/jest-dom @testing-library/react jsdom msw vite vitest
```

## codegenの設定ファイル作成

自分自身がよく使っている設定ではありますが記載していきます

```zsh:zsh
touch frontend/codegen.ts
```

```ts:frontend/codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  overwrite: true,
  schema: "../backend/src/schema.gql",
  documents: "src/**/*.gql",
  hooks: {
    afterAllFileWrite: ["prettier --write"],
  },
  generates: {
    "src/types/graphql.gen.ts": {
      plugins: [
        "@graphql-codegen/typescript",
        "@graphql-codegen/typescript-resolvers",
      ],
      config: {
        enumsAsTypes: true,
        namingConvention: "keep",
        avoidOptionals: true,
        scalars: {
          BigInt: " string",
          ISO8601Date: "string",
          ISO8601DateTime: "string",
        },
      },
    },
    "src/": {
      preset: "near-operation-file-preset",
      presetConfig: {
        extension: ".gen.ts",
        baseTypesPath: "types/graphql.gen.ts",
      },
      plugins: [
        "@graphql-codegen/typescript-operations",
        "@graphql-codegen/typescript-react-apollo",
        "@graphql-codegen/typescript-msw",
      ],
      config: {
        gqlImport: "@apollo/client#gql",
        constEnums: true,
        reactApolloVersion: 3,
        withComponent: false,
        withHOC: false,
        withHooks: true,
        enumsAsTypes: true,
        namingConvention: "keep",
        avoidOptionals: true,
      },
    },
  },
};

export default config;
```

コードが書けたらpackage.jsonのscriptsにcodegenの実行コマンドを追加していきます

```diff_json:frontend/package.json
{
  "name": "frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview",
+    "codegen": "graphql-codegen --config codegen.ts",
    "test": "vitest"
  },
  ...
}
```

<details><summary>テスト周りの設定</summary>

オプション的な立ち位置のテストになるので説明はないです

```ts:frontend/setupTests.ts
import "@testing-library/jest-dom";
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import * as UserGen from "./src/gql/user.gen";
import { mockAuthUser } from "./src/mocks/handlers";
import { server } from "./src/mocks/server";

export const mockIdToken = "mockIdToken";
export const mockUid = "mockUid";

beforeAll(() => server.listen({ onUnhandledRequest: "warn" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

vi.mock(
  "firebase/auth",
  async (importOrigin: () => Promise<typeof import("firebase/auth")>) => {
    const actual = await importOrigin();
    return {
      ...actual,
      getAuth: () => ({
        onAuthStateChanged: vi.fn((callback) => {
          callback({
            uid: mockUid,
            displayName: mockAuthUser.name,
            getIdToken: vi.fn().mockResolvedValue(mockIdToken),
          });
        }),
        currentUser: null,
      }),
      createUserWithEmailAndPassword: async (email: string) => {
        const userCredential = {
          user: {
            uid: mockUid,
            email,
            getIdToken: vi.fn().mockResolvedValue(mockIdToken),
          },
        };
        return userCredential;
      },
      signInWithEmailAndPassword: async (email: string) => {
        const userCredential = {
          user: {
            uid: mockUid,
            email,
            getIdToken: vi.fn().mockResolvedValue(mockIdToken),
          },
        };
        return userCredential;
      },
      signOut: vi.fn().mockResolvedValue(undefined),
    };
  }
);

vi.mock("./src/gql/user.gen", async (importOrigin) => {
  const actual = (await importOrigin()) as typeof UserGen;
  return {
    ...actual,
    useGetUserLazyQuery: actual.useGetUserLazyQuery,
    useGetUsersLazyQuery: actual.useGetUsersLazyQuery,
    useCreateUserMutation: actual.useCreateUserMutation,
  };
});

beforeAll(() => {
  global.localStorage = {
    getItem: vi.fn(),
    setItem: vi.fn(),
    clear: vi.fn(),
    length: 0,
    key: vi.fn().mockReturnValue(null),
    removeItem: vi.fn(),
  };
});
```

テストの設定追加
```diff_typescript:frontend/vite.config.ts
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
import { configDefaults } from "vitest/config";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    host: "0.0.0.0",
    port: 3000,
  },
+  test: {
+    globals: true,
+    environment: "jsdom",
+    setupFiles: "./setupTests.ts",
+    include: ["**/__tests__/**/*.test.(tsx|ts)"],
+    exclude: [
+      ...configDefaults.exclude,
+      "**/assets/**",
+      "**/coverage/**",
+      "**/dist/**",
+      "src/app/types/**",
+      "**/*.config.ts",
+    ],
+    coverage: {
+      exclude: [
+        ...(configDefaults.coverage.exclude ?? []),
+        "**/assets/**",
+        "**/coverage/**",
+        "**/dist/**",
+        "src/app/types/**",
+        "**/*.config.ts",
+      ],
+    },
+  },
});
```

モック作成
```ts:frontend/src/mocks/handlers.ts
import { HttpResponse } from "msw";
import {
  mockCreateUserMutation,
  mockGetUserQuery,
  mockGetUsersQuery,
} from "../gql/user.gen";

export const mockUser = {
  name: "mock",
  email: "mock@example.com",
};

const mockUserPassword = "password1234";

export const mockAuthUser = {
  ...mockUser,
  password: mockUserPassword,
};

export const mockUsers = [
  { name: "John Doe", email: "john@example.com" },
  { name: "Jane Doe", email: "jane@example.com" },
];

export const handlers = [
  mockGetUserQuery(() => {
    return HttpResponse.json({
      data: {
        user: mockUser,
      },
    });
  }),

  mockGetUsersQuery(() => {
    return HttpResponse.json({
      data: {
        users: mockUsers,
      },
    });
  }),

  mockCreateUserMutation(({ variables }) => {
    const name = variables?.name as string;

    return HttpResponse.json({
      data: {
        createUser: {
          name,
          email: mockUser.email,
        },
      },
    });
  }),
];
```

```ts:frontend/src/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);
```
</details>

## gqlファイル作成
今回は
* CreateUser
    * ユーザーデータ作成を行うMutation
* GetUser
    * uuidを元にユーザーデータを取得するQuery
* GetUsers
    * 全ユーザーデータを取得するQuery

をの3つを記載します

```zsh:zsh
mkdir -p frontend/gql
touch frontend/gql/user.gql
```

```graphql:frontend/gql/user.gql
query GetUser {
  user {
    name
    email
  }
}

query GetUsers {
  users {
    name
    email
  }
}

mutation CreateUser($name: String!) {
  createUser(createUserOnlyNameInput: { name: $name }) {
    name
    email
  }
}
```

作成出来たらターミナルにてcodegenを実行してファイルを生成してください
```zsh:zsh
npm run codegen
```

## Firebase設定
Firebase周りの設定を書いていきます
多くの記事で見るようなコードになるため読みやすいかと思います

各ファイルの役割としては
* firebaseInit.ts
    * Firebaseの初期化を行う
* firebase.ts
    * バックエンド側に送るJWTを取得するための処理を持つ
* useFirebase.ts
    * 新規アカウント作成・ログイン・ログアウトの処理を持ったカスタムフック
    * localStorageにJWTを入れていますが、実際に運用するプロジェクトではやらないようにしてください
        * チェック用に記載しています

```zsh:zsh
mkdir frontend/src/auth
touch frontend/src/auth/firebaseInit.ts
touch frontend/src/auth/firebase.ts
touch frontend/src/auth/useFirebase.ts
```

```ts:frontend/src/auth/firebaseInit.ts
import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
};

const app = initializeApp(firebaseConfig);

export const auth = getAuth(app);
```

```ts:frontend/src/auth/firebase.ts
import { auth } from "./firebaseInit";

export const getIdToken = async (): Promise<string | null> => {
  return new Promise((resolve) => {
    auth.onAuthStateChanged(async (user) => {
      if (user) {
        const token = await user.getIdToken();
        resolve(token);
      } else {
        resolve(null);
      }
    });
  });
};
```

```ts:frontend/src/auth/useFirebase.ts
import { FirebaseError } from "firebase/app";
import {
  createUserWithEmailAndPassword,
  signInWithEmailAndPassword,
  signOut,
} from "firebase/auth";
import { useCallback } from "react";
import { auth } from "./firebaseInit";

type FirebaseAuthArg = Record<"email" | "password", string>;

type FirebaseLogin = (arg: FirebaseAuthArg) => Promise<void>;
type FirebaseCreate = (arg: FirebaseAuthArg) => Promise<void>;

type UseFirebaseReturn = {
  firebaseLogin: FirebaseLogin;
  firebaseCreate: FirebaseCreate;
  firebaseLogout: () => Promise<void>;
};
type UseFirebase = () => UseFirebaseReturn;

export const useFirebase: UseFirebase = () => {
  const firebaseLogin = useCallback<FirebaseLogin>(
    async ({ email, password }) => {
      try {
        const userCredential = await signInWithEmailAndPassword(
          auth,
          email,
          password
        );

        // NOTE: Playgroundで実行用に
        const token = await userCredential.user.getIdToken();
        localStorage.setItem("jwtToken", token);
      } catch (error) {
        const message = handleFirebaseError(error);
        console.error("Error:", message);
      }
    },
    []
  );

  const firebaseCreate = useCallback<FirebaseCreate>(
    async ({ email, password }) => {
      try {
        const userCredential = await createUserWithEmailAndPassword(
          auth,
          email,
          password
        );
        const token = await userCredential.user.getIdToken();

        localStorage.setItem("jwtToken", token);
      } catch (error) {
        const message = handleFirebaseError(error);
        console.error("Error:", message);
      }
    },
    []
  );

  const firebaseLogout = useCallback(async () => {
    try {
      await signOut(auth);
    } catch (error) {
      const message = handleFirebaseError(error);
      console.error("Error:", message, error);
    }
    localStorage.removeItem("jwtToken");
  }, []);

  return {
    firebaseLogin,
    firebaseCreate,
    firebaseLogout,
  };
};

const handleFirebaseError = (error: unknown) => {
  if (error instanceof FirebaseError) {
    switch (error.code) {
      case "auth/user-not-found":
        return "ユーザーが見つかりません";
      case "auth/wrong-password":
        return "パスワードが間違っています";
      case "auth/email-already-in-use":
        return "このメールアドレスはすでに登録されています";
      case "auth/network-request-failed":
        return "ネットワークエラーが発生しました";
      default:
        return "エラー発生しました";
    }
  }
  return "不明なエラーが発生しました";
};
```

<details><summary>テスト</summary>

```ts:frontend/src/auth/__tests__/useFirebase.test.ts
import { renderHook } from "@testing-library/react";
import { FirebaseError } from "firebase/app";
import { createUserWithEmailAndPassword } from "firebase/auth";
import { act } from "react";
import { describe, expect, it, vi } from "vitest";
import { mockIdToken } from "../../../setupTests";
import { mockAuthUser } from "../../mocks/handlers";
import { useFirebase } from "../useFirebase";

describe("useFirebase", () => {
  it("初期状態", () => {
    const { result } = renderHook(() => useFirebase());
    expect(result.current.firebaseCreate).toBeDefined();
    expect(result.current.firebaseLogin).toBeDefined();
    expect(result.current.firebaseLogout).toBeDefined();
  });

  it("ユーザー登録に成功する", async () => {
    const { result } = renderHook(() => useFirebase());

    await act(async () => {
      await result.current.firebaseCreate({
        email: mockAuthUser.email,
        password: mockAuthUser.password,
      });
    });

    expect(localStorage.setItem).toHaveBeenCalledWith("jwtToken", mockIdToken);
  });

  it("ログインに成功する", async () => {
    const { result } = renderHook(() => useFirebase());

    await act(async () => {
      await result.current.firebaseLogin({
        email: mockAuthUser.email,
        password: mockAuthUser.password,
      });
    });

    expect(localStorage.setItem).toHaveBeenCalledWith("jwtToken", mockIdToken);
  });

  it("ログアウトに成功する", async () => {
    const { result } = renderHook(() => useFirebase());

    await act(async () => {
      await result.current.firebaseLogout();
    });

    expect(localStorage.removeItem).toHaveBeenCalledWith("jwtToken");
  });

  it("ユーザー登録に失敗する", async () => {
    const networkError = new FirebaseError(
      "auth/network-request-failed",
      "Network error occurred"
    );

    const mockCreateUserWithEmailAndPassword = vi
      .fn()
      .mockRejectedValue(networkError) as typeof createUserWithEmailAndPassword;

    const originalCreateUserWithEmailAndPassword =
      createUserWithEmailAndPassword;

    (createUserWithEmailAndPassword as typeof mockCreateUserWithEmailAndPassword) =
      mockCreateUserWithEmailAndPassword;

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

    await act(async () => {
      try {
        await result.current.firebaseCreate({
          email: mockAuthUser.email,
          password: mockAuthUser.password,
        });
      } catch (error) {
        expect(error).toEqual(networkError);
      }
    });

    expect(mockCreateUserWithEmailAndPassword).toHaveBeenCalledTimes(1);
    expect(mockCreateUserWithEmailAndPassword).toHaveBeenCalledWith(
      expect.anything(),
      mockAuthUser.email,
      mockAuthUser.password
    );

    // 元の実装を復元
    (createUserWithEmailAndPassword as typeof mockCreateUserWithEmailAndPassword) =
      originalCreateUserWithEmailAndPassword;
  });
});
```
</details>

## apolloClient設定
よくあるapolloClientのこーどになりますが、今回大事なのは`getIdToken()`が記載されている箇所になります
バックエンドへ送る際のheaderに`getIdToken()`で取得したJWTをセットして送る形にしています
ログインしていない場合は空文字を送るようにしています

```zsh:zsh
mkdir frontend/src/configs
mkdir frontend/src/providers
touch frontend/src/configs/apolloClient.ts
touch frontend/src/providers/apolloProvider.ts
```

```ts:frontend/src/configs/apolloClient.ts
import { ApolloClient, InMemoryCache, createHttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { getIdToken } from "../auth/firebase";

const httpLink = createHttpLink({
  uri: import.meta.env.VITE_BACKEND_URI,
});

const authLink = setContext(async (_, { headers }) => {
  const token = await getIdToken();

  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : "",
    },
  };
});

const client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache(),
});

export default client;
```

```tsx:frontend/src/providers/apolloProvider.tsx
import { ApolloProvider as Provider } from "@apollo/client";
import { FC, ReactNode } from "react";
import client from "../configs/apolloClient";

interface ApolloProviderProps {
  children: ReactNode;
}

export const ApolloProvider: FC<ApolloProviderProps> = ({ children }) => {
  return <Provider client={client}>{children}</Provider>;
};
```
`apolloProvider.tsx`まで書けたら`main.tsx`で呼んであげます
呼ぶことで全ページにて使用できるようにしてあげます

```tsx:frontend/src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { ApolloProvider } from "./providers/apolloProvider.tsx";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <ApolloProvider>
      <App />
    </ApolloProvider>
  </StrictMode>
);
```

## ページ作成
作成するページは
* create.tsx
    * 新規アカウント作成ページ
* login.tsx
    * ログインページ
* userWithLogin.tsx
    * ログインしたアカウントのみデータ取得出来るページ
* userWithoutLogin.tsx
    * ログイン状況関係なくデータ取得出来るページ

になります

```zsh:zsh
mkdir frontend/src/page
touch frontend/src/page/create.tsx
touch frontend/src/page/login.tsx
touch frontend/src/page/userWithLogin.tsx
touch frontend/src/page/userWithoutLogin.tsx
```

```tsx:frontend/src/page/create.tsx
import { ChangeEvent, FC, useCallback, useState } from "react";
import { useFirebase } from "../auth/useFirebase";
import { useCreateUserMutation } from "../gql/user.gen";

export const Create: FC = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [name, setName] = useState("");
  const [user, setUser] = useState<{ name: string; email: string } | null>(
    null
  );

  const { firebaseCreate } = useFirebase();
  const [createUser] = useCreateUserMutation();

  const createHandler = useCallback(async () => {
    try {
      await firebaseCreate({ email, password });
      const newUser = await createUser({ variables: { name } });
      console.dir(newUser, { depth: null });
      if (newUser.data) {
        const { name, email } = newUser.data.createUser;
        setUser({ name, email });
      }
    } catch (error) {
      console.error("Create failed:", error);
    }
  }, [firebaseCreate, email, password, createUser, name]);

  const setterEmail = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value),
    []
  );

  const setterPassword = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => setPassword(e.target.value),
    []
  );

  const setterName = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => setName(e.target.value),
    []
  );

  return (
    <div>
      <h1>Create</h1>

      <div>
        <label htmlFor="email">Email</label>
        <br />
        <input id="email" type="text" onChange={setterEmail} />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <br />
        <input id="password" type="password" onChange={setterPassword} />
      </div>
      <div>
        <label htmlFor="name">Name</label>
        <br />
        <input id="name" type="text" onChange={setterName} />
      </div>

      <br />

      <button onClick={createHandler}>新規作成</button>

      {user && (
        <div>
          <p>
            Name: <span>{user.name}</span>
          </p>
          <p>
            Email: <span>{user.email}</span>
          </p>
        </div>
      )}
    </div>
  );
};
```

```tsx:frontend/src/page/login.tsx
import { FC, useCallback, useState } from "react";
import { useFirebase } from "../auth/useFirebase";

export const Login: FC = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const { firebaseLogin, firebaseLogout } = useFirebase();

  const loginHandler = useCallback(
    async () =>
      await firebaseLogin({
        email,
        password,
      }),
    [firebaseLogin, email, password]
  );

  const setterEmail = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value),
    []
  );

  const setterPassword = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value),
    []
  );

  return (
    <div>
      <h1>Login</h1>

      <div>
        <label htmlFor="email">Email</label>
        <br />
        <input id="email" type="text" onChange={setterEmail} />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <br />
        <input id="password" type="password" onChange={setterPassword} />
      </div>

      <br />

      <button onClick={loginHandler}>ログイン</button>
      <button onClick={firebaseLogout}>ログアウト</button>
    </div>
  );
};
```

```tsx:frontend/src/page/userWithLogin.tsx
import { FC, useEffect } from "react";
import { useGetUserLazyQuery } from "../gql/user.gen";

export const UserWithLogin: FC = () => {
  const [getUser, { loading, data, error }] = useGetUserLazyQuery({
    fetchPolicy: "cache-and-network",
  });

  useEffect(() => {
    getUser();
  }, [getUser]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>UserWithLogin</h1>
      {data ? (
        <div>
          <h1>{data.user.name}</h1>
          <p>{data.user.email}</p>
        </div>
      ) : (
        <p>Not Data</p>
      )}
    </div>
  );
};
```

```tsx:frontend/src/page/userWithoutLogin.tsx
import { FC, useEffect } from "react";
import { useGetUsersLazyQuery } from "../gql/user.gen";

export const UserWithoutLogin: FC = () => {
  const [getUsers, { data, loading, error }] = useGetUsersLazyQuery({
    fetchPolicy: "network-only",
  });

  useEffect(() => {
    getUsers();
  }, [getUsers]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>UserWithoutLogin</h1>
      {data?.users.map((user, index) => (
        <div key={index}>
          <h1>{user.name}</h1>
          <p>{user.email}</p>
        </div>
      ))}
    </div>
  );
};
```

<details><summary>テスト</summary>

```tsx:frontend/src/page/__tests__/create.test.tsx
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { useFirebase } from "../../auth/useFirebase";
import { mockAuthUser } from "../../mocks/handlers";
import { ApolloProvider } from "../../providers/apolloProvider";
import { Create } from "../create";

vi.mock("../../auth/useFirebase.ts", () => ({
  useFirebase: vi.fn(),
}));

const mockFirebaseCreate = vi.fn();

beforeEach(() => {
  (useFirebase as Mock).mockReturnValue({
    firebaseCreate: mockFirebaseCreate,
  });
  vi.clearAllMocks();
});

describe("create", () => {
  it("新規作成ボタンをクリックでユーザー作成処理が成功する", async () => {
    render(
      <ApolloProvider>
        <Create />
      </ApolloProvider>
    );

    fireEvent.change(screen.getByLabelText("Email"), {
      target: { value: mockAuthUser.email },
    });
    fireEvent.change(screen.getByLabelText("Password"), {
      target: { value: mockAuthUser.password },
    });
    fireEvent.change(screen.getByLabelText("Name"), {
      target: { value: mockAuthUser.name },
    });

    fireEvent.click(screen.getByText("新規作成"));

    await waitFor(() => {
      expect(mockFirebaseCreate).toHaveBeenCalledWith({
        email: mockAuthUser.email,
        password: mockAuthUser.password,
      });
    });

    expect(await screen.findByText(mockAuthUser.name)).toBeInTheDocument();
    expect(await screen.findByText(mockAuthUser.email)).toBeInTheDocument();
  });
});
```

```tsx:frontend/src/page/__tests__/login.test.tsx
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { useFirebase } from "../../auth/useFirebase";
import { mockAuthUser } from "../../mocks/handlers";
import { Login } from "../login";

vi.mock("../../auth/useFirebase.ts", () => ({
  useFirebase: vi.fn(),
}));

const mockFirebaseLogin = vi.fn();
const mockFirebaseLogout = vi.fn();

beforeEach(() => {
  (useFirebase as Mock).mockReturnValue({
    firebaseLogin: mockFirebaseLogin,
    firebaseLogout: mockFirebaseLogout,
  });
  vi.clearAllMocks();
});

describe("login", () => {
  it("ログインボタンをクリックでログイン処理が成功する", async () => {
    render(<Login />);

    fireEvent.change(screen.getByLabelText("Email"), {
      target: { value: mockAuthUser.email },
    });
    fireEvent.change(screen.getByLabelText("Password"), {
      target: { value: mockAuthUser.password },
    });

    fireEvent.click(screen.getByText("ログイン"));

    await waitFor(() => {
      expect(mockFirebaseLogin).toHaveBeenCalledWith({
        email: mockAuthUser.email,
        password: mockAuthUser.password,
      });
    });
  });

  it("ログアウトボタンをクリックしてログアウト処理が成功する", async () => {
    render(<Login />);

    fireEvent.click(screen.getByText("ログアウト"));

    await waitFor(() => {
      expect(mockFirebaseLogout).toHaveBeenCalled();
    });
  });
});
```

```tsx:frontend/src/page/__tests__/userWithLogin.test.tsx
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { mockUser } from "../../mocks/handlers";
import { ApolloProvider } from "../../providers/apolloProvider";
import { UserWithLogin } from "../userWithLogin";

describe("テスト", () => {
  it("ローディングが表示されること", () => {
    render(
      <ApolloProvider>
        <UserWithLogin />
      </ApolloProvider>
    );

    expect(screen.getByText("Loading...")).toBeInTheDocument();
  });

  it("ユーザーデータが表示されること", async () => {
    render(
      <ApolloProvider>
        <UserWithLogin />
      </ApolloProvider>
    );

    expect(await screen.findByText(mockUser.name)).toBeInTheDocument();
    expect(await screen.findByText(mockUser.email)).toBeInTheDocument();
  });
});
```

```tsx:frontend/src/page/__tests__/userWithoutLogin.test.tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { mockUsers } from "../../mocks/handlers";
import { ApolloProvider } from "../../providers/apolloProvider";
import { UserWithoutLogin } from "../userWithoutLogin";

describe("UserWithoutLogin", () => {
  it("ローディングが表示される", () => {
    render(
      <ApolloProvider>
        <UserWithoutLogin />
      </ApolloProvider>
    );

    expect(screen.getByText("Loading...")).toBeInTheDocument();
  });

  it("ユーザーデータが表示される", async () => {
    render(
      <ApolloProvider>
        <UserWithoutLogin />
      </ApolloProvider>
    );

    mockUsers.forEach(async (user) => {
      expect(await screen.findByText(user.name)).toBeInTheDocument();
      expect(await screen.findByText(user.email)).toBeInTheDocument();
    });
  });
});
```
</details>

## ルーティング設定
ページの作成完了致しましたので最後にルーティング設定をしていきます
実運用ならルーティングに権限処理を実装すべきですが今回はオミットしています

```
mkdir frontend/src/router
touch frontend/src/router/router.tsx
```
```tsx:frontend/src/router/router.tsx
import { BrowserRouter, Link, Route, Routes } from "react-router-dom";
import { Create } from "../page/create";
import { Login } from "../page/login";
import { UserWithLogin } from "../page/userWithLogin";
import { UserWithoutLogin } from "../page/userWithoutLogin";

export const Router = () => {
  return (
    <BrowserRouter>
      <Link to="/">Login</Link> | <Link to="/create">Create</Link> |{" "}
      <Link to="/userWithoutLogin">userWithoutLogin</Link> |{" "}
      <Link to="/userWithLogin">userWithLogin</Link> |{" "}
      <Routes>
        <Route path="/" Component={Login} />
        <Route path="/userWithoutLogin" Component={UserWithoutLogin} />
        <Route path="/userWithLogin" Component={UserWithLogin} />
        <Route path="/create" Component={Create} />
      </Routes>
    </BrowserRouter>
  );
};
```

`router.tsx`が書けたら`App.tsx`を更新していきます
```tsx:frontend/src/App.tsx
import "./App.css";
import { Router } from "./router/router";

function App() {
  return <Router />;
}

export default App;
```


# 動作チェック
ここまで実装できたらフロント側問題なく表示されているか確認してみます

* ログインページ
![CleanShot 2024-11-30 at 07.43.37@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/06a828bd-ff70-b26d-e7cc-147fa9f4a9f3.png)
* 新規アカウント作成ページ
![CleanShot 2024-11-30 at 07.44.35@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/e6e78526-da47-a713-6ea8-4d5715fddff7.png)
* ログイン状態関係なくデータ取得できるページ
![CleanShot 2024-11-30 at 10.11.47@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/bd8bd40f-e433-757b-4e80-e428aa32e6d4.png)
* ログイン状況関係なくデータ取得出来るページ(現状ログインしていないため`Token not found`と表示されている)
![CleanShot 2024-11-30 at 07.47.05@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/ac0dc8a8-a86f-95c8-c43b-8266cacb8a4a.png)

上手く動くところを見ると楽しいですね
![CleanShot 2024-11-30 at 10.38.47.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/cfa2d6d9-3a56-5cad-6c02-2774f466c051.gif)

# 今後
今回は`uuid`と`email`のみアカウント情報から取得していますが
カスタムクレームを使用すればより厳格な認証が行えると思うのでそちらも試せていければと思っています