Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .env.pgsql.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost

# dev: local/testing環境限定でbackup/seed等をrole制限なしで許可。
# production(未設定時のデフォルト、安全側): 常に厳格。ローカル開発で緩めたい場合のみ明示的に dev に変更する。
APP_MODE=production

# test/reset(デモDB初期化)を叩くための共有secret。X-Test-Reset-Tokenヘッダがこの値と
# 一致しないと403で拒否。未設定なら常に拒否(fail-closed)。dev環境でE2E等から使う場合のみ設定する。
TEST_RESET_TOKEN=

APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US

APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database

# PHP_CLI_SERVER_WORKERS=4

BCRYPT_ROUNDS=12

LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug

# PostgreSQL接続 (scripts/pgsql-dev.sh が起動するローカルコンテナの既定値)
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=quotebuilder
DB_USERNAME=quotebuilder
DB_PASSWORD=quotebuilder

SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null

BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
BACKUP_DISK=local
BACKUP_DIRECTORY=backups
BACKUP_RETENTION_DAYS=30
BACKUP_MAX_GENERATIONS=20
QUEUE_CONNECTION=database

CACHE_STORE=database
# CACHE_PREFIX=

MEMCACHED_HOST=127.0.0.1

REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false

VITE_APP_NAME="${APP_NAME}"
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

## コンセプト

- 個人〜少人数、PC1台での運用を前提とした最小構成(SQLite・単一サーバー)
- 個人〜少人数、PC1台での運用を前提とした最小構成(単一サーバー。現行はSQLite、PostgreSQLをprimary DBへ移行予定: [#62](https://github.com/strnh/quotebuilder/issues/62)
- 過去の見積ファイル(xlsx/ods、quotebuilder 出力PDF)を取り込んで移行できる
- データは利用者自身の手元に置き、バックアップ・復元も自己完結できる

Expand All @@ -16,7 +16,7 @@
|---|---|
| Backend | PHP 8.3 / Laravel 13 |
| Frontend | React 19 + TypeScript / Vite / Tailwind CSS 4(SPA、同一オリジン配信) |
| DB | SQLite(PostgreSQL対応は検討中: [#27](https://github.com/strnh/quotebuilder/issues/27)) |
| DB | SQLite(PostgreSQLをprimary DBへ移行予定: [#62](https://github.com/strnh/quotebuilder/issues/62)。SQLiteは移行元/legacy扱いへ) |
| 取込 | PhpSpreadsheet(xlsx / ods) |
| 集計表示 | Recharts |
| テスト | PHPUnit(Feature)/ Playwright(E2E)/ tsc |
Expand Down Expand Up @@ -70,7 +70,7 @@
| バックアップ管理画面の拡充 | [#22](https://github.com/strnh/quotebuilder/issues/22) | 画面からの復元操作 |
| 差分バックアップ | [#23](https://github.com/strnh/quotebuilder/issues/23) | 方式設計とMVP範囲の確定 |
| 見積一覧のTAB化 | [#17](https://github.com/strnh/quotebuilder/issues/17) | ステータス別TABでの一覧表示 |
| PostgreSQL対応 | [#27](https://github.com/strnh/quotebuilder/issues/27) | DB pluginの検討 |
| PostgreSQL移行 | [#62](https://github.com/strnh/quotebuilder/issues/62) | PostgreSQLをprimary DB化([#27](https://github.com/strnh/quotebuilder/issues/27) の検討を包含) |
| Windows簡易アプリ化 | [#2](https://github.com/strnh/quotebuilder/issues/2) | NativePHPによる非技術者向け配布 |

さらに先の構想として、OAuth2 / MFA(メール)による認証強化があります(#5 に記載)。
Expand Down
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"test:pgsql": [
"@php artisan config:clear --ansi @no_additional_args",
"vendor/bin/phpunit -c phpunit.pgsql.xml"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ public function up(): void
->whereNotNull('customer_signature')
->where('customer_signature', '!=', '')
->groupBy('sig')
->having('n', '>', 1)
// PostgreSQL は HAVING で SELECT の別名(n)を参照できないため集約式で書く
->havingRaw('count(*) > 1')
->pluck('sig');

if ($duplicates->isNotEmpty()) {
Expand Down
46 changes: 46 additions & 0 deletions phpunit.pgsql.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- PostgreSQL実行用プロファイル (#62)。scripts/pgsql-dev.sh start で起動した
ローカルコンテナの quotebuilder_test に接続する。
使い方: composer run test:pgsql
DB_HOST/DB_PORT/DB_USERNAME/DB_PASSWORD は環境変数で上書き可。
DB_CONNECTION と DB_DATABASE は誤って開発DBへ向けないよう force で固定する。 -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MODE" value="production"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="pgsql" force="true"/>
<env name="DB_HOST" value="127.0.0.1"/>
<env name="DB_PORT" value="5432"/>
<env name="DB_DATABASE" value="quotebuilder_test" force="true"/>
<env name="DB_USERNAME" value="quotebuilder"/>
<env name="DB_PASSWORD" value="quotebuilder"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>
111 changes: 111 additions & 0 deletions scripts/pgsql-dev.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# 開発用 PostgreSQL コンテナの起動/停止/破棄 (podman 優先、なければ docker)
# 使い方: scripts/pgsql-dev.sh [start|stop|destroy|status]
set -euo pipefail

cd "$(dirname "$0")/.."

RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; RESET='\033[0m'
step() { echo -e "${CYAN}==> $*${RESET}"; }
ok() { echo -e "${GREEN}OK: $*${RESET}"; }
die() { echo -e "${RED}ERROR: $*${RESET}" >&2; exit 1; }

CONTAINER_NAME="${PGSQL_CONTAINER_NAME:-quotebuilder-pgsql}"
VOLUME_NAME="${PGSQL_VOLUME_NAME:-quotebuilder-pgsql-data}"
IMAGE="${PGSQL_IMAGE:-docker.io/library/postgres:17-alpine}"
HOST_PORT="${PGSQL_PORT:-5432}"
DB_NAME="${PGSQL_DB:-quotebuilder}"
DB_TEST_NAME="${PGSQL_TEST_DB:-quotebuilder_test}"
DB_USER="${PGSQL_USER:-quotebuilder}"
DB_PASSWORD="${PGSQL_PASSWORD:-quotebuilder}"

if command -v podman &>/dev/null; then
ENGINE=podman
elif command -v docker &>/dev/null; then
ENGINE=docker
else
die "podman / docker が見つかりません"
fi

container_exists() { "$ENGINE" container exists "$CONTAINER_NAME" 2>/dev/null || "$ENGINE" inspect "$CONTAINER_NAME" &>/dev/null; }
container_running() { [ "$("$ENGINE" inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" = "true" ]; }

wait_ready() {
step "PostgreSQL の起動待ち"
for _ in $(seq 1 30); do
if "$ENGINE" exec "$CONTAINER_NAME" pg_isready -U "$DB_USER" -d "$DB_NAME" &>/dev/null; then
ok "PostgreSQL が応答しています (port ${HOST_PORT})"
return 0
fi
sleep 1
done
die "PostgreSQL が 30 秒以内に起動しませんでした ($ENGINE logs $CONTAINER_NAME を確認)"
}

ensure_test_db() {
step "テスト用 DB (${DB_TEST_NAME}) の確認"
if "$ENGINE" exec "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -tAc \
"SELECT 1 FROM pg_database WHERE datname = '${DB_TEST_NAME}'" | grep -q 1; then
ok "${DB_TEST_NAME} は既に存在します"
else
"$ENGINE" exec "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -c \
"CREATE DATABASE ${DB_TEST_NAME} OWNER ${DB_USER}" >/dev/null
ok "${DB_TEST_NAME} を作成しました"
fi
}

cmd="${1:-start}"
case "$cmd" in
start)
if container_running; then
ok "コンテナ ${CONTAINER_NAME} は起動済みです"
elif container_exists; then
step "既存コンテナ ${CONTAINER_NAME} を起動"
"$ENGINE" start "$CONTAINER_NAME" >/dev/null
else
step "コンテナ ${CONTAINER_NAME} を作成して起動 (${IMAGE})"
"$ENGINE" run -d \
--name "$CONTAINER_NAME" \
-e POSTGRES_DB="$DB_NAME" \
-e POSTGRES_USER="$DB_USER" \
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
-p "${HOST_PORT}:5432" \
-v "${VOLUME_NAME}:/var/lib/postgresql/data" \
"$IMAGE" >/dev/null
fi
wait_ready
ensure_test_db
echo ""
echo "接続設定は .env.pgsql.example を参照 (cp .env.pgsql.example .env)"
;;
stop)
if container_running; then
"$ENGINE" stop "$CONTAINER_NAME" >/dev/null
ok "コンテナ ${CONTAINER_NAME} を停止しました"
else
ok "コンテナ ${CONTAINER_NAME} は起動していません"
fi
;;
destroy)
if container_exists; then
"$ENGINE" rm -f "$CONTAINER_NAME" >/dev/null
ok "コンテナ ${CONTAINER_NAME} を削除しました"
fi
if "$ENGINE" volume exists "$VOLUME_NAME" 2>/dev/null || "$ENGINE" volume inspect "$VOLUME_NAME" &>/dev/null; then
"$ENGINE" volume rm "$VOLUME_NAME" >/dev/null
ok "ボリューム ${VOLUME_NAME} を削除しました (データは失われます)"
fi
;;
status)
if container_running; then
ok "コンテナ ${CONTAINER_NAME} は起動中 (port ${HOST_PORT})"
elif container_exists; then
echo "コンテナ ${CONTAINER_NAME} は停止中"
else
echo "コンテナ ${CONTAINER_NAME} は未作成"
fi
;;
*)
die "不明なサブコマンド: $cmd (start|stop|destroy|status)"
;;
esac
15 changes: 13 additions & 2 deletions tests/Feature/BackupStorageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,40 @@ class BackupStorageTest extends TestCase

private string $databasePath;

private string $originalDefaultConnection;

protected function setUp(): void
{
parent::setUp();
Storage::fake('backups-test');
config()->set('backup.disk', 'backups-test');
config()->set('backup.directory', 'private-backups');
// default接続を一時SQLiteへ切り替える前に、migrate済みのプロファイル既定接続で作る
// (切替後だと手書きスキーマの一時DBへinsertされ、pgsqlプロファイルで失敗する)。
$this->actingAs(User::factory()->create(['role' => 'admin']));
$this->databasePath = sys_get_temp_dir().'/backup-storage-'.bin2hex(random_bytes(6)).'.sqlite';
$this->originalDefaultConnection = config('database.default');
config()->set('database.default', 'sqlite');
config()->set('database.connections.sqlite.database', $this->databasePath);
$database = new SQLite3($this->databasePath);
$database->exec('CREATE TABLE migrations (id INTEGER PRIMARY KEY, migration TEXT, batch INTEGER)');
$database->exec("INSERT INTO migrations VALUES (1, 'create_quotes', 1)");
$database->exec('CREATE TABLE quotes (id INTEGER PRIMARY KEY, title TEXT)');
$database->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, password TEXT, role TEXT, created_at TEXT, updated_at TEXT)');
$database->exec('CREATE TABLE backup_audit_logs (id INTEGER PRIMARY KEY, actor_type TEXT, actor_identifier TEXT, action TEXT, target_type TEXT, target_identifier TEXT, outcome TEXT, failure_reason TEXT, created_at TEXT, updated_at TEXT)');
// 実 migration (backup_audit_logs + note 追加) と同じ列構成にする。pgsql プロファイルでは
// request 中の監査ログ書き込みがこの一時DBへ向かうため、乖離すると 500 になる。
$database->exec('CREATE TABLE backup_audit_logs (id INTEGER PRIMARY KEY, actor_type TEXT, actor_identifier TEXT, user_id INTEGER, operation TEXT, target TEXT, result TEXT, failure_reason TEXT, note TEXT, created_at TEXT)');
$database->close();
$this->actingAs(User::factory()->create(['role' => 'admin']));
}

protected function tearDown(): void
{
Carbon::setTestNow();
@unlink($this->databasePath);
// RefreshDatabase のロールバックは「この時点の default 接続」に向かうため、
// 切り替えたままだとプロファイル既定接続(pgsql等)のトランザクションが残置され、
// 次のテストの migrate がロック待ちでハングする。必ず元に戻してから破棄する。
config()->set('database.default', $this->originalDefaultConnection);
parent::tearDown();
}

Expand Down