【テスト駆動開発】Springboot & React - 第8回 : プロフィル修正
1. はじめに
こんにちは、前回はユーザープロフィールを実装しました。
今回はユーザープロファイルの内容を修正する機能を実装してみます。おおよその方向は、バックエンドではユーザー情報修正時、認証関連設定、DTOを通じたサービスロジックを実装します。フロントエンドでユーザーログインとボタンによる状態値を利用して修正ボタンを作り、バックエンドにAPIを送信するロジックを実装します。
今日の実装完成の画面です。
2. 実装過程
2.1 ユーザー修正時、セキュリティーの設定
/UserControllerTest.java
@Test
public void putUser_whenUnauthorizedUserSendsTheRequest_receiveUnauthorized() {
ResponseEntity<Object> response = putUser(123, null, Object.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
public void putUser_whenAuthorizedUserSendsUpdateForAnotherUser_receiveForbidden() {
User user = userService.save(TestUtil.createValidUser("user1"));
authenticate(user.getUsername());
long anotherUserId = user.getId() + 123;
ResponseEntity<Object> response = putUser(anotherUserId, null, Object.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
public void putUser_whenUnauthorizedUserSendsTheRequest_receiveApiError() {
ResponseEntity<ApiError> response = putUser(123, null, ApiError.class);
assertThat(response.getBody().getUrl()).contains("users/123");
}
@Test
public void putUser_whenAuthorizedUserSendsUpdateForAnotherUser_receiveApiError() {
User user = userService.save(TestUtil.createValidUser("user1"));
authenticate(user.getUsername());
long anotherUserId = user.getId() + 123;
ResponseEntity<ApiError> response = putUser(anotherUserId, null, ApiError.class);
assertThat(response.getBody().getUrl()).contains("users/"+anotherUserId);
}
...
public <T> ResponseEntity<T> putUser(long id, HttpEntity<?> requestEntity, Class<T> responseType){
String path = API_1_0_USERS + "/" + id;
return testRestTemplate.exchange(path, HttpMethod.PUT, requestEntity, responseType);
}
}
putUser_whenUnauthorizedUserSendsTheRequest_receiveUnauthorized:
認証されていないユーザーがリクエストを送信すると、UNAUTHORIZEDのステータスコードを受け取る必要があります。
putUser_whenAuthorizedUserSendsUpdateForAnotherUser_receiveForbidden:
認証されたユーザーが他のユーザーを対象に更新しようとすると、FORBIDDENのステータスコードを受け取る必要があります。
putUser_whenUnauthorizedUserSendsTheRequest_receiveApiError:
認証されていないユーザーがリクエストを送信すると、ApiErrorオブジェクトを受け取り、そのオブジェクトのURLは「users/123」を含む必要があります。
putUser_whenAuthorizedUserSendsUpdateForAnotherUser_receiveApiError:
認証されたユーザーが他のユーザーを対象に更新しようとすると、ApiErrorオブジェクトを受け取り、そのオブジェクトのURLは「users/123」など、対象ユーザーのIDを含む必要があります。
/user/UserController.java
@PutMapping("/users/{id:[0-9]+}")
@PreAuthorize("#id == principal.id")
void updateUser(@PathVariable long id) {
}
{id:[0-9]+}は、"id"が1つ以上の数字で構成されていることを示しています。
@PreAuthorize("#id == principal.id")は、メソッドが呼び出される前に実行される権限の検証を示しています。この場合、#idはメソッドの引数である"id"を指し、principal.idは認証されたユーザーのIDを指します。この検証は、リクエストされたIDが認証されたユーザーのIDと一致する場合にメソッドが実行されることを保証します。
/configuration/SecurityConfiguration.java
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter{
@Autowired
AuthUserService authUserService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.httpBasic().authenticationEntryPoint(new BasicAuthenticationEntryPoint());
http
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/api/1.0/login").authenticated()
.antMatchers(HttpMethod.PUT, "/api/1.0/users/{id:[0-9]+}").authenticated()
.and()
.authorizeRequests().anyRequest().permitAll();
@EnableGlobalMethodSecurityアノテーションを使用して、メソッドレベルのセキュリティ構成を有効にしています。
.antMatchers(HttpMethod.PUT, "/api/1.0/users/{id:[0-9]+}").authenticated()は、特定のエンドポイントに対するHTTP PUTメソッドのリクエストに対するセキュリティルールを設定しています。ユーザーが認証されている必要があると指定されています。
2.2. ユーザー修正ロジックにUserVM(DTO)反映
/UserControllerTest.java
@Test
public void putUser_whenValidRequestBodyFromAuthorizedUser_receiveOk() {
User user = userService.save(TestUtil.createValidUser("user1"));
authenticate(user.getUsername());
UserUpdateVM updatedUser = createValidUserUpdateVM();
HttpEntity<UserUpdateVM> requestEntity = new HttpEntity<>(updatedUser);
ResponseEntity<Object> response = putUser(user.getId(), requestEntity, Object.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
public void putUser_whenValidRequestBodyFromAuthorizedUser_displayNameUpdated() {
User user = userService.save(TestUtil.createValidUser("user1"));
authenticate(user.getUsername());
UserUpdateVM updatedUser = createValidUserUpdateVM();
HttpEntity<UserUpdateVM> requestEntity = new HttpEntity<>(updatedUser);
putUser(user.getId(), requestEntity, Object.class);
User userInDB = userRepository.findByUsername("user1");
assertThat(userInDB.getDisplayName()).isEqualTo(updatedUser.getDisplayName());
}
@Test
public void putUser_whenValidRequestBodyFromAuthorizedUser_receiveUserVMWithUpdatedDisplayName() {
User user = userService.save(TestUtil.createValidUser("user1"));
authenticate(user.getUsername());
UserUpdateVM updatedUser = createValidUserUpdateVM();
HttpEntity<UserUpdateVM> requestEntity = new HttpEntity<>(updatedUser);
ResponseEntity<UserVM> response = putUser(user.getId(), requestEntity, UserVM.class);
assertThat(response.getBody().getDisplayName()).isEqualTo(updatedUser.getDisplayName());
}
private UserUpdateVM createValidUserUpdateVM() {
UserUpdateVM updatedUser = new UserUpdateVM();
updatedUser.setDisplayName("newDisplayName");
return updatedUser;
}
putUser_whenValidRequestBodyFromAuthorizedUser_receiveOk:
認証されたユーザーが有効なリクエストボディを送信すると、HTTPステータスコードがOK(200)であることを確認します。
putUser_whenValidRequestBodyFromAuthorizedUser_displayNameUpdated:
認証されたユーザーが有効なリクエストボディを送信すると、ユーザーの表示名が更新されていることを確認します。
putUser_whenValidRequestBodyFromAuthorizedUser_receiveUserVMWithUpdatedDisplayName:
認証されたユーザーが有効なリクエストボディを送信すると、更新された表示名を含むUserVMオブジェクトを受け取ることを確認します。
/user/vm/UserUpdateVM.java
@Data
public class UserUpdateVM {
private String displayName;
}
user/UserController.java
@PutMapping("/users/{id:[0-9]+}")
@PreAuthorize("#id == principal.id")
UserVM updateUser(@PathVariable long id, @RequestBody(required = false) UserUpdateVM userUpdate) {
User updated = userService.update(id, userUpdate);
return new UserVM(updated);
}
戻り値のタイプをUserVMで受け取ります。
/user/UserService.java
public User update(long id, UserUpdateVM userUpdate) {
User inDB = userRepository.getOne(id);
inDB.setDisplayName(userUpdate.getDisplayName());
return userRepository.save(inDB);
}
UserUpdateVMオブジェクトから表示名(displayName)を取得し、対応するユーザーをデータベースから取得します。その後、取得したユーザーの表示名を更新し、更新されたユーザー情報をデータベースに保存します。最終的に、更新されたユーザーオブジェクトが返されます。
2.3 ユーザー修正のAPI
frontend/src/api/apiCalls.spec.js
describe('updateUser', () => {
it('calls /api/1.0/users/5 when 5 is provided for updateUser', () => {
const mockUpdateUser = jest.fn();
axios.put = mockUpdateUser;
apiCalls.updateUser('5');
const path = mockUpdateUser.mock.calls[0][0];
expect(path).toBe('/api/1.0/users/5');
});
});
updateUser関数が正しくaxios.putを呼び出していることを確認しています。
frontend/src/api/apiCalls.js
export const updateUser = (userId, body) => {
return axios.put('/api/1.0/users/' + userId, body);
};xport const updateUser = (userId, body) => {
return axios.put('/api/1.0/users/' + userId, body);
};
関数はaxios.putの結果を返します。この結果はPromiseとして扱われ、呼び出し元で .then() や .catch() を使用して処理できます。
修正ボタンを実装します。
frontend/src/components/ProfileCard.spec.js
it('displays edit button when isEditable property set as true', () => {
const { queryByText } = render(
<ProfileCard user={user} isEditable={true} />
);
const editButton = queryByText('Edit');
expect(editButton).toBeInTheDocument();
});
it('does not display edit button when isEditable not provided', () => {
const { queryByText } = render(<ProfileCard user={user} />);
const editButton = queryByText('Edit');
expect(editButton).not.toBeInTheDocument();
});
'displays edit button when isEditable property set as true':
isEditableがtrueに設定されている場合、'Edit'ボタンが表示されていることを確認します。
'does not display edit button when isEditable not provided':
isEditableが提供されていない場合、'Edit'ボタンが表示されていないことを確認します。
frontend/src/components/ProfileCard.js
<div className="card-body text-center">
<h4>{`${displayName}@${username}`}</h4>
{props.isEditable && (
<button className="btn btn-outline-success">
<i className="fas fa-user-edit" /> Edit
</button>
)}
</div>
{props.isEditable && ...}: isEditableがtrueの場合には、<button>要素が表示されます。これにより、編集可能な場合のみ"Edit"ボタンが表示されます。
frontend/src/pages/UserPage.spec.js
import { Provider } from 'react-redux';
import configureStore from '../redux/configureStore';
import axios from 'axios';
…
…
const setup = (props) => {
const store = configureStore(false);
return render(
<Provider store={store}>
<UserPage {...props} />
</Provider>
);
};
beforeEach(() => {
localStorage.clear();
delete axios.defaults.headers.common['Authorization'];
});
const setUserOneLoggedInStorage = () => {
localStorage.setItem(
'hoax-auth',
JSON.stringify({
id: 1,
username: 'user1',
displayName: 'display1',
image: 'profile1.png',
password: 'P4ssword',
isLoggedIn: true
})
);
};
…
it('displays the edit button when loggedInUser matches to user in url', async () => {
setUserOneLoggedInStorage();
apiCalls.getUser = jest.fn().mockResolvedValue(mockSuccessGetUser);
const { queryByText } = setup({ match });
await waitForElement(() => queryByText('display1@user1'));
const editButton = queryByText('Edit');
expect(editButton).toBeInTheDocument();
});
setup 関数: Reduxストアをセットアップし、<UserPage> コンポーネントを <Provider>でラップしています。これにより、Reduxストアがコンポーネントに提供されます。
beforeEach ブロック: テストごとにlocalStorageとaxiosの認証ヘッダーをクリアします。これにより、各テストが独立して実行されることが確認されます。
setUserOneLoggedInStorage 関数: localStorageにモックのログインユーザー情報を設定します。これにより、テスト内でログインユーザーとして使われます。
it('displays the edit button when loggedInUser matches to user in url', async () => { ... }): ログインユーザーがURLのユーザーと一致する場合、編集ボタンが表示されることを確認するテストケースです。
frontend/src/pages/UserPage.js
import { connect } from 'react-redux';
…
render() {
let pageContent;
if (this.state.isLoadingUser) {
pageContent = (
<div className="d-flex">
<div className="spinner-border text-black-50 m-auto">
<span className="sr-only">Loading...</span>
</div>
</div>
);
} else if (this.state.userNotFound) {
pageContent = (
<div className="alert alert-danger text-center">
<div className="alert-heading">
<i className="fas fa-exclamation-triangle fa-3x" />
</div>
<h5>User not found</h5>
</div>
);
} else {
const isEditable =
this.props.loggedInUser.username === this.props.match.params.username;
pageContent = this.state.user && (
<ProfileCard user={this.state.user} isEditable={isEditable} />
);
}
return <div data-testid="userpage">{pageContent}</div>;
}
}
…
const mapStateToProps = (state) => {
return {
loggedInUser: state
};
};
export default connect(mapStateToProps)(UserPage);
<UserPage> コンポーネントは connect 関数を使用してReduxストアに接続されています。これにより、Reduxストアの状態にアクセスできます。
mapStateToProps 関数: Reduxストアの状態をコンポーネントのプロパティにマッピングします。この場合、loggedInUserプロパティがReduxストアの状態としてマッピングされています。
isEditable: 現在のログインユーザーが表示されているユーザーのプロフィールを編集できるかどうかを示すフラグ。
ProfileCard: ProfileCardコンポーネントが表示される場合、ユーザー情報と編集可能フラグが渡されます。
修正ボタンとキャンセルボタンのレイアウトを実装します。
frontend/src/components/ProfileCard.js
import Input from './Input';
const ProfileCard = (props) => {
const { displayName, username, image } = props.user;
const showEditButton = props.isEditable && !props.inEditMode;
return (
<div className="card">
<div className="card-header text-center">
<ProfileImageWithDefault
alt="profile"
width="200"
height="200"
image={image}
className="rounded-circle shadow"
/>
</div>
<div className="card-body text-center">
{!props.inEditMode && <h4>{`${displayName}@${username}`}</h4>}
{props.inEditMode && (
<div className="mb-2">
<Input
value={displayName}
label={`Change Display Name for ${username}`}
/>
</div>
)}
{showEditButton && (
<button className="btn btn-outline-success">
<i className="fas fa-user-edit" /> Edit
</button>
)}
{props.inEditMode && (
<div>
<button className="btn btn-primary">
<i className="fas fa-save" /> Save
</button>
<button className="btn btn-outline-secondary ml-1">
<i className="fas fa-window-close" /> Cancel
</button>
</div>
)}
</div>
</div>
);
const showEditButton = props.isEditable && !props.inEditMode;: 編集可能でかつ編集モードでない場合に編集ボタンを表示するかどうかの条件を設定しています。
{!props.inEditMode && <h4>{${displayName}@${username}}</h4>}: 編集モードでない場合、表示名とユーザー名を表示します。
{props.inEditMode && ...}: 編集モードの場合、表示名を変更するための Input コンポーネントと保存・キャンセルボタンを表示します。
{showEditButton && ...}: 編集可能かつ編集モードでない場合、編集ボタンを表示します。
{props.inEditMode && ...}: 編集モードの場合、保存ボタンとキャンセルボタンを表示します。
修正ボタンを押すと修正レイアウトが表示され、キャンセルボタンを押すと元に戻るようにします。
frontend/src/pages/UserPage.js
state = {
user: undefined,
userNotFound: false,
isLoadingUser: false,
inEditMode: false
};
...
onClickEdit = () => {
this.setState({
inEditMode: true
});
};
onClickCancel = () => {
this.setState({
inEditMode: false
});
};
...
render() {
let pageContent;
if (this.state.isLoadingUser) {
...
} else {
const isEditable =
this.props.loggedInUser.username === this.props.match.params.username;
pageContent = this.state.user && (
<ProfileCard
user={this.state.user}
isEditable={isEditable}
inEditMode={this.state.inEditMode}
onClickEdit={this.onClickEdit}
onClickCancel={this.onClickCancel}
/>
);
}
レンダリングロジック: ユーザー情報が読み込まれている場合、ログインユーザーが編集可能かどうかを確認し、それに基づいて <ProfileCard> コンポーネントをレンダリングします。
frontend/src/components/ProfileCard.js
return (
<div className="card">
...
{showEditButton && (
<button
className="btn btn-outline-success"
onClick={props.onClickEdit}
>
<i className="fas fa-user-edit" /> Edit
</button>
)}
...
{props.inEditMode && (
<div>
<button className="btn btn-primary">
<i className="fas fa-save" /> Save
</button>
<button
className="btn btn-outline-secondary ml-1"
onClick={props.onClickCancel}
>
<i className="fas fa-window-close" /> Cancel
</button>
</div>
)}
Reactで、<i>要素は主にアイコンやスタイルが適用されたコンテンツを表すために使用されます。
2.4 ユーザー修正APIの送信
frontend/src/pages/UserPage.js
onClickSave = () => {
const userId = this.props.loggedInUser.id;
const userUpdate = {
displayName: this.state.user.displayName
};
apiCalls.updateUser(userId, userUpdate).then((response) => {
this.setState({
inEditMode: false
});
});
};
onChangeDisplayName = (event) => {
const user = { ...this.state.user };
user.displayName = event.target.value;
this.setState({ user });
};
render() {
let pageContent;
if (this.state.isLoadingUser) {
…
} else {
const isEditable =
this.props.loggedInUser.username === this.props.match.params.username;
pageContent = this.state.user && (
<ProfileCard
user={this.state.user}
isEditable={isEditable}
inEditMode={this.state.inEditMode}
onClickEdit={this.onClickEdit}
onClickCancel={this.onClickCancel}
onClickSave={this.onClickSave}
onChangeDisplayName={this.onChangeDisplayName}
/>
);
}
onChangeDisplayName メソッド: 表示名の入力フィールドの値が変更されたときに呼び出され、入力された値を元にuserオブジェクトを更新し、それを状態に反映します。
{ ...this.state.user }は、JavaScriptでオブジェクトの浅いコピー(shallow copy)を作成する方法です。これにより、新しいオブジェクトが作成され、元のオブジェクトのプロパティがコピーされます。このパターンは、オブジェクトの変更を元のオブジェクトに影響を与えずに行いたい場合に便利です。状態の変更が元のuserオブジェクトに影響を与えず、正しく再レンダリングされることが確保されます。
frontend/src/components/ProfileCard.js
return (
…
<div className="card-body text-center">
{!props.inEditMode && <h4>{`${displayName}@${username}`}</h4>}
{props.inEditMode && (
<div className="mb-2">
<Input
value={displayName}
label={`Change Display Name for ${username}`}
onChange={props.onChangeDisplayName}
/>
</div>
…
{props.inEditMode && (
<div>
<button className="btn btn-primary" onClick={props.onClickSave}>
<i className="fas fa-save" /> Save
</button>
編集モードではユーザーが表示名を変更できるようになっています。
3. 最後に
今までバックエンドとフロントエンドでユーザープロファイル情報を修正する機能を実装してみました。 認証情報をずっとチェックするためバックエンドではSpringSecurity@EnableGlobalMethodSecuiryを活用したのが印象的でした。特に、prePostEnabledはメソッドが呼び出される前または後に特定のセキュリティ条件をチェックするために使用されます。
フロントエンド側では下記のロジックを使って認証されたユーザーである時、ユーザー情報修正コンポーネントをレンダリングします。
... else {
const isEditable =
this.props.loggedInUser.username === this.props.match.params.username;
pageContent = this.state.user && (
<ProfileCard user={this.state.user} isEditable={isEditable} />
);
とにかく、修正機能において一般的に実装される方式は、現在認証されたユーザーの状態を確認する方式で行われるので、このような機能が重要だと言えます。
エンジニアファーストの会社 株式会社CRE-CO
ソンさん
【参考】
[Udemy] Spring Boot and React with Test Driven Development