0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

🛒小さな材料発注リストを本番仕様へ|DB基盤→CRUD→Navigation→Mutation Testing

0
Posted at

3470.png

📌 この記事を5行でまとめると

  • ベーカリー向け売上管理アプリに、材料発注リストを追加しました。
  • Change IでDataset分離・migration・Guest cleanupなどのデータ基盤を実装。
  • Change JでCRUDを追加し、PostgreSQLで100件上限・並行操作を検証。
  • Change KでPC sidebar・Mobile drawer・下部ナビを追加し、Navigationを刷新。
  • PR #28を12通り意図的に壊し、12件すべて既存pytestがREDになりました。

はじめに

現在、ベーカリー向け売上管理Webアプリケーション

🍞 sales_data_app 🥖

を個人開発している tosane932 です。

直前まではGuest Demo公開後の総点検として、

JST
Dashboard期間
logout
Gemini timeout
PostgreSQL CI
並行処理

など、主に既存機能を安全に使い続けるための防御開発を進めていました。

前回の記事はこちらです。

今回は久しぶりに、

店舗で実際に使う新しい業務機能

として、

【🛒 材料発注リスト】

を追加しました。

ただし、いきなり画面を作ったわけではありません。

Change I
データ基盤

↓

Change J
CRUD + PostgreSQL並行処理

↓

Change K
Navigation / UI / UX基盤

という順番で進めました。


なぜ材料発注リストなのか

ベーカリーでは日々、

強力粉
バター
牛乳
卵
包装資材

など、購入・発注するものが発生します。

ただし今回作りたかったのは、

在庫管理
仕入先管理
原価計算
発注書
納品管理

まで含む大規模なシステムではありません。

イメージは、

□ 強力粉 2袋
□ バター 5個
□ 牛乳 1ケース

のような、

今日買うもの・注文するものを、スマホですぐ確認できるチェックリスト

です。

そのため、既存のProductやRecipeとはあえて結びつけず、

入力の速さと分かりやすさ

を優先しました。


Change I:まずデータ基盤を作る

PR:

最初に作ったのは画面ではなく、

DBモデル
Dataset境界
migration
Guest cleanup
rollback
PostgreSQL

です。

migration
DB構造の変更履歴を安全に適用する仕組みです。

rollback
途中で失敗したときに、変更を元へ戻す処理です。

追加したモデルは、

MaterialOrderItem

です。

Column 用途
id Primary Key
dataset_id Dataset分離
name 材料名
quantity_text 数量・単位
memo メモ
is_completed 完了状態
created_at 作成時刻
completed_at 完了時刻

数量は、

2袋
5個
1ケース
500g

のような入力を優先し、

quantity_text

として文字列で保持します。


Dataset配下へ置く

sales_data_appでは、

Admin
Guest A
Guest B

をDatasetで分離しています。

MaterialOrderItemも同様に、

Dataset
└─ MaterialOrderItem

という構造にしました。

FKには、

ON DELETE CASCADE

を設定。

Guest cleanupでも、期限切れDataset配下の材料発注データを明示的に削除します。

例えば、

Guest A:期限切れ
Guest B:有効
Admin:有効

なら、

Guest A → 削除
Guest B → 保持
Admin   → 保持

となることをpytestで確認しています。

cleanup途中でDB例外が起きた場合も、

rollback

によって、

Dataset
MaterialOrderItem
Product
DailySales

が途中まで消えた状態を残さないことを確認しました。


Alembic migration

新しいrevisionは、

d4f7a9c2e6b1

です。

SQLite・PostgreSQLともにmigration経路を確認し、既存データが維持されることも検証しました。

Change I終了時点:

437 passed
0 failed
0 skipped

Change Iでは画面を作っていません

この段階では、

route
HTML
CSS
JavaScript
CRUD

はまだありません。

先に、

材料発注データを安全に保存・削除できる土台

だけを作りました。


Change J:CRUDと並行処理

PR:

次に、

GET  /material-orders
POST /material-orders
POST /material-orders/<id>/completion
POST /material-orders/<id>/delete

を追加しました。

機能は、

一覧
追加
完了
未完了へ戻す
削除

です。

新機能はmaterial_orders.pyというBlueprintへ分離し、既存の認証とDataset resolverを外から渡しています。


Dataset越境を防ぐ

更新・削除対象は、

id = item_id
AND
dataset_id = current_dataset.id

を同一queryで絞り込みます。

そのためGuest AがGuest BのIDを知っていても、

完了
未完了
削除

はできません。

存在しないIDも、他DatasetのIDも同じ404にしています。

追加時のdataset_idも利用者入力から受け取らず、サーバー側で現在のDatasetを決定します。

IDOR
URLなどのIDを書き換えることで、他人のデータを操作できてしまう問題です。


CSRF / XSS / validation

状態変更はすべてPOSTです。

追加
完了
未完了
削除

について、

CSRF tokenなし
改ざんtoken

ではDBが変更されないことを確認しました。

利用者入力についてもJinja autoescapeを維持し、

|safe
Markup
innerHTML

は使っていません。

入力上限は、

項目 制限
材料名 必須・最大100文字
数量・単位 任意・最大30文字
メモ 任意・最大300文字

としています。


100件上限をPostgreSQLで守る

1 Datasetあたり、

未完了 + 完了済み

を合わせて最大100件です。

単純に、

COUNT
↓
INSERT

するだけでは、

99件の状態で2requestが同時に来たとき、

101件

になる可能性があります。

そこで、

Dataset row
↓
SELECT ... FOR UPDATE
↓
COUNT
↓
INSERT
↓
COMMIT

という順にしました。

row lock
同じデータを同時に書き換えないよう、一時的に順番待ちさせる仕組みです。

PostgreSQL 16で99件の状態から2requestを同時送信した結果、

成功:303
上限拒否:400
最終件数:100

となりました。

さらにpg_stat_activityで、

2本目が実際にrow lock待ちになったこと

まで確認しています。


GREEN後の監査で競合を発見

Change J終了時点では、

475 passed

でした。

しかし、

pytestがGREENでも、まだ壊せるかもしれない

という前提で総合監査を行ったところ、

Critical:0
High:0
Medium:1

となりました。

見つかったのは、

同一MaterialOrderItemへの並行操作

です。

例えば、

completion + completion
completion + reopen
completion + delete

が同時に発生した場合です。


MaterialOrderItem自体をlockする

修正後は、

MaterialOrderItem.query.filter_by(
    id=item_id,
    dataset_id=current_dataset.id,
).populate_existing().with_for_update().one_or_none()

としました。

ポイントは、

row lock
+
lock待ち後に最新状態を再読込

することです。

公式ドキュメント:

PostgreSQL 16 Explicit Locking

SQLAlchemy with_for_update()

SQLAlchemy populate_existing


実PostgreSQLで並行操作を確認

競合 最終結果
completion + completion 2本とも303、時刻更新は1回
completion + reopen 最終状態は未完了
completion + delete 最終状態は削除済み

いずれも、

500なし
CHECK違反なし
transaction破損なし

でした。

Change J最終結果:

478 passed
0 failed
0 skipped

PostgreSQL integration:

13 passed

Change K:アプリ全体のNavigationを作る

PR:

材料発注を追加したことで、

商品登録
日次売上入力
Dashboard
材料発注

と機能が増えてきました。

トップページへリンクを増やし続けるより、

アプリ全体の移動方法を先に整える

ことにしました。


PCは固定sidebar

PCでは左側に固定sidebarを追加しました。

主な入口は、

商品登録
日次売上入力
Dashboard
店舗メモツール

です。

現在地は、

aria-current="page"
背景
太字
左側ライン

などを組み合わせ、

色だけに頼らず分かるようにしています。

3452.jpg


Mobileはhamburger drawer

スマホでは、

☰ メニュー

から開くdrawer形式にしました。

Esc操作、focus制御、aria-expandedaria-controlsなども含めて確認しています。

3446.jpg

3447.jpg


店舗メモツールという小さな器

材料発注を作る中で、

🛒 発注
📝 メモ
✅ タスク

を一つのまとまりとして扱うことにしました。

Mobileでは固定下部Navigationを置いています。

現在は、

発注:実装済み
メモ:準備中
タスク:準備中

です。

ここではメモやタスクまで一気に作らず、

次の機能を追加できる器だけ先に整えました。

3457.jpg


材料追加FAB

材料発注には、

のFloating Action Buttonも追加しました。

押すと既存の材料名入力欄へfocusします。

スマホでは64pxの丸ボタンにし、600px以下では文字ラベルを隠しています。


Local Development Mode

Change Kをlocalhostで確認している途中、

503
Admin login rate limiting client IP is missing or invalid.

になりました。

productionではCloudflareの、

CF-Connecting-IP

だけを信頼するfail-closed設計ですが、localhostにはそのheaderがありません。

そこでproductionを緩めるのではなく、

LOCAL_DEVELOPMENT=true

を明示した場合だけ、

request.remote_addr
SESSION_COOKIE_SECURE=False

を利用するモードを追加しました。

LOCAL_DEVELOPMENT=trueは開発環境専用です。

productionでは未設定またはfalseとし、従来のfail-closedとSecure Cookieを維持します。


Change K終了時点

full pytest:

526 passed
13 skipped

PostgreSQL integration:

13 passed

GitHub Actions:

test:SUCCESS
postgres-integration:SUCCESS

通常のfull pytestでは、隔離PostgreSQL環境が必要なtestを分離しているため13件skipします。

PostgreSQL専用jobでは、その13件を実際に実行してGREENを確認しています。


526件のpytestは、本当に故障を検知できるのか?

ここで自分でも、

pytestが増えているだけでは?

と気になりました。

そこでPR #28だけを対象に、軽量なMutation Testingを行いました。

Mutation Testing
あえてコードを壊して、pytestが本当に故障を検知できるか確認する方法です。

正常なコードを1箇所ずつ意図的に壊し、

pytestがREDになる
→ KILLED

GREENのまま
→ SURVIVED

として確認します。


12種類の故障を入れる

# Mutation 結果
1 1 / yes / trueeでもLocal Developmentを有効化 KILLED
2 本番でCloudflare header欠損時にfallback KILLED
3 不正なCloudflare IPを許可 KILLED
4 Local DevelopmentでもCloudflare headerを使用 KILLED
5 通常環境でもSecure Cookieを無効化 KILLED
6 Dashboard linkを誤配線 KILLED
7 aria-currentを無効化 KILLED
8 Admin / Guest表示を反転 KILLED
9 logoutをPOSTからGETへ変更 KILLED
10 logoutからCSRF tokenを削除 KILLED
11 Memo routeの認証を削除 KILLED
12 FABのaria-labelを誤値へ変更 KILLED

結果:

12 KILLED
0 SURVIVED

つまり今回確認した12種類については、

実際にコードを壊すと既存pytestがREDになりました。


Mutation後の最終確認

各Mutationは、

1つ壊す
↓
pytest RED確認
↓
元へ戻す
↓
次へ

という順で実施しました。

最終状態:

526 passed
13 skipped

git diff --check
問題なし

git status
clean

Mutation用コードは残っていません。

今回のMutation TestingはPR #28限定です。

アプリ全体の安全性や、すべてのコードパスを証明するものではありません。

確認できたのは、

PR #28の重要な12種類の故障を、既存pytestが実際に検知できた

というところまでです。


小さい機能でも、裏側は小さくない

利用者から見る材料発注リストは、

材料を書く
完了にする
削除する

くらいの小さな機能です。

しかし裏では、

Dataset分離
CSRF
IDOR
XSS
rollback
migration
Guest cleanup
100件上限
並行request
row lock
Navigation
Mutation Testing

まで考えることになりました。

利用者には、

そんなことまで?

と思われるくらいでちょうどいいのかもしれません。

簡単に見えるように、裏側で面倒なことを引き受ける

こともWebアプリ側の役割だと思っています。


次はChange L:ようやくメモ機能へ

Change Kで、

🛒 発注
📝 メモ
✅ タスク

という店舗メモツールの器ができました。

次は、

Change L:メモ機能

へ進みます。

一般的にはCRUD学習の序盤で作ることも多いメモアプリですが、

sales_data_appでは、

認証
CSRF
Dataset分離
Guest lifecycle
cleanup
migration
PostgreSQL
並行処理
CI
500件以上のpytest

などを先に整えたあとで、ようやく作ることになりました。

地盤と基礎を徹底的に固め、壊れないか何度も確認したあと、広い土地の真ん中にようやく小さなメモアプリを建て始めます。

ただし、高機能なメモアプリを作ること自体が目的ではありません。

売上管理をしている途中で、

明日は食パンを多めにしよう
○○業者へ価格確認
新商品の試作を忘れない

といったことを、

別のアプリへ移動せず、その場で残せる

ようにしたいと考えています。

機能数そのものではなく、

このシステムを使う価値を少しずつ高めること

を優先します。

売上入力のときだけ開くシステムではなく、

毎日の仕事のそばに置いてもらえるアプリ

へ育てていきたいと思っています。


おわりに

今回のChange I〜Kは、

Change I
データ基盤

↓

Change J
CRUD + PostgreSQL並行処理

↓

反証監査
同一row競合を修正

↓

Change K
Navigation / UI / UX
Local Development

↓

Mutation Testing
12 / 12 KILLED

という流れになりました。

最終結果:

full pytest
526 passed
13 skipped

PostgreSQL integration
13 passed

GitHub Actions
test:SUCCESS
postgres-integration:SUCCESS

Mutation Testing
12 KILLED
0 SURVIVED

✅ Change I〜Kはここで一区切り

PR:

Change I:#26
Change J:#27
Change K:#28

すべてmainへmerge済みです。

Change Lはすでに開始しており、まずタイトルなし自由メモのデータ基盤まで実装しました。次はMemo CRUDへ進みます。


関連リンク

GitHub

Change I

Change J

Change K

前回の記事

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?