diff --git a/.bear/ci/README.md b/.bear/ci/README.md index 3a2659a..02f64e1 100644 --- a/.bear/ci/README.md +++ b/.bear/ci/README.md @@ -8,10 +8,10 @@ Files: - `baseline-allow.json`: exact-match allow file for approved boundary expansion Canonical outputs: -- console summary lines for `MODE`, `CHECK`, and `PR-CHECK` +- console summary starts with `BEAR Decision: PASS|REVIEW REQUIRED|FAIL|ALLOWED EXPANSION`, followed by the structured `MODE=... DECISION=... BASE=...` line and the `CHECK` / `PR-CHECK` lines - optional `ALLOW_ENTRY_CANDIDATE` block on enforce-mode boundary expansion - report artifact at `build/bear/ci/bear-ci-report.json` -- markdown summary at `build/bear/ci/bear-ci-summary.md` +- markdown summary at `build/bear/ci/bear-ci-summary.md`, using the same `BEAR Decision: ...` header near the top - when `GITHUB_STEP_SUMMARY` is set, the wrapper appends the exact markdown summary content there Canonical usage: @@ -19,13 +19,13 @@ Canonical usage: PowerShell: ```powershell -.\.bear\ci\bear-gates.ps1 --mode enforce +.\.bear\ci\bear-gates.ps1 --mode observe ``` bash: ```sh -./.bear/ci/bear-gates.sh --mode enforce +./.bear/ci/bear-gates.sh --mode observe ``` Options: @@ -35,6 +35,7 @@ Options: Rules: - wrappers run `check --all` first, then `pr-check --all` when allowed by the pinned decision matrix +- in `observe`, clean runs report `pass`, boundary expansion reports `review-required`, and blocking repo problems report `fail` - `baseline-allow.json` is consulted only for `pr-check` boundary expansion in `enforce` - the allow-entry candidate and markdown boundary section use the full boundary-expanding delta set from `pr-check --all` repo-level plus block-level results - report and decision output must be reproducible from BEAR raw outputs plus wrapper mode and allow-file state @@ -42,4 +43,4 @@ Rules: Runtime note: - on bash-based GitHub runners, `bear-gates.sh` requires `pwsh` - if `pwsh` is unavailable, `bear-gates.sh` fails deterministically and tells the operator to install PowerShell 7 or run `bear-gates.ps1` directly -- if local bash cannot launch PowerShell reliably, run `bear-gates.ps1` directly \ No newline at end of file +- if local bash cannot launch PowerShell reliably, run `bear-gates.ps1` directly diff --git a/.bear/ci/bear-gates.ps1 b/.bear/ci/bear-gates.ps1 index 6678940..a1a7619 100644 --- a/.bear/ci/bear-gates.ps1 +++ b/.bear/ci/bear-gates.ps1 @@ -60,6 +60,22 @@ function Get-PropertyValue($object, $name) { return $property.Value } +function Get-FirstNormalizedValue($value) { + if ($null -eq $value) { + return $null + } + if ($value -is [string]) { + return [string]$value + } + foreach ($item in @($value)) { + $candidate = [string]$item + if (-not [string]::IsNullOrWhiteSpace($candidate)) { + return $candidate + } + } + return $null +} + function Normalize-Lines($text) { if ($null -eq $text -or $text.Length -eq 0) { return @() @@ -117,6 +133,82 @@ function Parse-FailureFooter($text, $exitCode) { } } +function Parse-AgentFailure($agentJson, $exitCode) { + if ($exitCode -eq 0) { + return [ordered]@{ + valid = $true + code = $null + path = $null + remediation = $null + } + } + if ($null -eq $agentJson) { + return New-InvalidFooter + } + $nextAction = Get-PropertyValue $agentJson 'nextAction' + $primaryClusterId = [string](Get-PropertyValue $nextAction 'primaryClusterId') + $clusters = New-OrderedArray (Get-PropertyValue $agentJson 'clusters') + $primaryCluster = $null + foreach ($cluster in $clusters) { + if ([string](Get-PropertyValue $cluster 'clusterId') -eq $primaryClusterId) { + $primaryCluster = $cluster + break + } + } + if ($null -eq $primaryCluster -and $clusters.Count -gt 0) { + $primaryCluster = $clusters[0] + } + $problems = New-OrderedArray (Get-PropertyValue $agentJson 'problems') + $primaryProblem = if ($problems.Count -gt 0) { $problems[0] } else { $null } + + $codeCandidates = @( + [string](Get-PropertyValue $primaryCluster 'reasonKey'), + [string](Get-PropertyValue $primaryCluster 'ruleId'), + [string](Get-PropertyValue $primaryCluster 'failureCode'), + [string](Get-PropertyValue $primaryProblem 'reasonKey'), + [string](Get-PropertyValue $primaryProblem 'ruleId'), + [string](Get-PropertyValue $primaryProblem 'failureCode'), + [string](Get-PropertyValue $primaryProblem 'messageKey') + ) + $code = $null + foreach ($candidate in $codeCandidates) { + if (-not [string]::IsNullOrWhiteSpace($candidate)) { + $code = $candidate + break + } + } + if ([string]::IsNullOrWhiteSpace($code)) { + return New-InvalidFooter + } + + $pathCandidates = @( + (Get-FirstNormalizedValue (Get-PropertyValue $primaryCluster 'files')), + [string](Get-PropertyValue $primaryProblem 'file') + ) + $path = $null + foreach ($candidate in $pathCandidates) { + if (-not [string]::IsNullOrWhiteSpace($candidate)) { + $path = $candidate + break + } + } + if ([string]::IsNullOrWhiteSpace($path)) { + $path = 'agent.json' + } + + $steps = New-OrderedArray (Get-PropertyValue $nextAction 'steps') + $remediation = if ($steps.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace([string]$steps[0])) { + [string]$steps[0] + } else { + 'Inspect BEAR agent diagnostics and apply the listed next action.' + } + return [ordered]@{ + valid = $true + code = $code + path = $path + remediation = $remediation + } +} function Try-ParseAgentJson($text) { if ([string]::IsNullOrWhiteSpace($text)) { return [ordered]@{ @@ -402,15 +494,30 @@ function Get-ClassesDisplay($classes) { return ($classes -join ',') } +function Get-DecisionHeader($decision) { + switch ($decision) { + 'pass' { return 'BEAR Decision: PASS' } + 'review-required' { return 'BEAR Decision: REVIEW REQUIRED' } + 'fail' { return 'BEAR Decision: FAIL' } + 'allowed-expansion' { return 'BEAR Decision: ALLOWED EXPANSION' } + default { return 'BEAR Decision: ' + $decision.ToUpperInvariant() } + } +} + function New-MarkdownSummary($modeValue, $decision, $baseResolution, $checkReport, $prReport, $combinedBoundaryDeltas, $allowEntryCandidate) { $baseDisplay = if ($baseResolution.resolved) { $baseResolution.value } else { 'unresolved' } $lines = New-Object System.Collections.Generic.List[string] $lines.Add('# BEAR CI Governance') $lines.Add('') + $lines.Add((Get-DecisionHeader $decision)) + $lines.Add('') $lines.Add('- Mode: ' + $modeValue) $lines.Add('- Decision: ' + $decision) $lines.Add('- Base SHA: ' + $baseDisplay) $lines.Add('- Report: build/bear/ci/bear-ci-report.json') + if ($decision -eq 'review-required') { + $lines.Add('- Review Required: boundary expansion detected.') + } $lines.Add('') $lines.Add('## Check') $lines.Add('- Exit: ' + $checkReport.exitCode) @@ -558,6 +665,9 @@ function Invoke-BearCommand($label, $commandText, $commandPath, $commandArgs) { $stderrHash = if (Test-Path $stderrPath) { (Get-FileHash -Algorithm SHA256 $stderrPath).Hash.ToLowerInvariant() } else { $null } $agent = Try-ParseAgentJson $stdoutText $footer = Parse-FailureFooter $stderrText $exitCode + if (-not $footer.valid -and $agent.valid) { + $footer = Parse-AgentFailure $agent.json $exitCode + } return [ordered]@{ label = $label command = $commandText @@ -622,15 +732,21 @@ try { $allowEntryCandidate = Get-AllowEntryCandidate $mode $prResult $prTelemetry $baseResolution.value $decision = 'pass' - if ($checkResult.exitCode -in @(2, 5, 64, 70, 74)) { + if ($checkClasses -contains 'CI_INTERNAL_ERROR') { + $decision = 'fail' + } elseif ($mode -eq 'observe' -and $checkResult.exitCode -in @(2, 3, 4, 5, 6, 7, 64, 70, 74)) { + $decision = 'fail' + } elseif ($checkResult.exitCode -in @(2, 5, 64, 70, 74)) { $decision = 'fail' } elseif (-not $baseResolution.resolved) { $decision = 'fail' } elseif ($null -eq $prResult) { $decision = 'fail' } elseif ($mode -eq 'observe') { - if ($prResult.exitCode -in @(2, 64, 70, 74)) { + if (($prClasses -contains 'CI_INTERNAL_ERROR') -or $prResult.exitCode -in @(2, 64, 70, 74)) { $decision = 'fail' + } elseif ($prResult.exitCode -eq 5) { + $decision = 'review-required' } } elseif ($checkResult.exitCode -ne 0) { $decision = 'fail' @@ -641,7 +757,6 @@ try { } else { $decision = 'fail' } - $checkReport = [ordered]@{ status = 'ran' exitCode = $checkResult.exitCode @@ -708,6 +823,7 @@ try { $baseDisplay = if ($baseResolution.resolved) { $baseResolution.value } else { '' } $checkCodeDisplay = Get-CodeDisplay $checkResult.footer.code $checkClassesDisplay = Get-ClassesDisplay $checkClasses + Write-Output (Get-DecisionHeader $decision) Write-Output ('MODE=' + $mode + ' DECISION=' + $decision + ' BASE=' + $baseDisplay) Write-Output ('CHECK exit=' + $checkResult.exitCode + ' code=' + $checkCodeDisplay + ' classes=' + $checkClassesDisplay) if ($prStatus -eq 'ran') { @@ -742,3 +858,5 @@ try { + + diff --git a/.bear/ci/bear-gates.sh b/.bear/ci/bear-gates.sh old mode 100644 new mode 100755 diff --git a/.bear/tools/bear-cli/bin/bear b/.bear/tools/bear-cli/bin/bear old mode 100644 new mode 100755 diff --git a/.bear/tools/bear-cli/lib/app-0.1.0-SNAPSHOT.jar b/.bear/tools/bear-cli/lib/app-0.1.0-SNAPSHOT.jar index 05cabb9..2eeebd2 100644 Binary files a/.bear/tools/bear-cli/lib/app-0.1.0-SNAPSHOT.jar and b/.bear/tools/bear-cli/lib/app-0.1.0-SNAPSHOT.jar differ diff --git a/.bear/tools/bear-cli/lib/kernel-0.1.0-SNAPSHOT.jar b/.bear/tools/bear-cli/lib/kernel-0.1.0-SNAPSHOT.jar index abd261c..c50c913 100644 Binary files a/.bear/tools/bear-cli/lib/kernel-0.1.0-SNAPSHOT.jar and b/.bear/tools/bear-cli/lib/kernel-0.1.0-SNAPSHOT.jar differ diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 82abeb5..547bcca 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -5,6 +5,8 @@ on: permissions: contents: read + issues: write + pull-requests: write jobs: governance: @@ -21,9 +23,70 @@ jobs: distribution: temurin java-version: '21' + - name: Generate BEAR artifacts + run: ./.bear/tools/bear-cli/bin/bear compile --all --project . + - name: Run BEAR governance (observe) run: ./.bear/ci/bear-gates.sh --mode observe + - name: Publish BEAR PR summary + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const marker = ''; + const reportPath = 'build/bear/ci/bear-ci-report.json'; + const summaryPath = 'build/bear/ci/bear-ci-summary.md'; + + let body = `${marker}\n## BEAR CI\n\nBEAR did not produce its expected CI report. Check the workflow logs.`; + if (fs.existsSync(reportPath) && fs.existsSync(summaryPath)) { + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + const summary = fs.readFileSync(summaryPath, 'utf8').trim(); + const decision = String(report.decision || 'unknown').toUpperCase().replace(/-/g, ' '); + const mode = report.mode || 'unknown'; + const baseSha = report.resolvedBaseSha || 'unknown'; + body = [ + marker, + '## BEAR CI', + '', + `- Decision: \`${decision}\``, + `- Mode: \`${mode}\``, + `- Base SHA: \`${baseSha}\``, + '', + '
BEAR summary', + '', + summary, + '', + '
' + ].join('\n'); + } + + const pr = context.payload.pull_request; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100 + }); + + const existing = comments.find((comment) => comment.user?.type === 'Bot' && comment.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body + }); + } + - name: Upload BEAR CI artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/AGENTS.md b/AGENTS.md index b74306b..8658c59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,13 @@ -# AGENTS.md (Project Bootstrap) +# AGENTS.md (Project Bootstrap) This project uses the BEAR agent profile. Startup (mandatory): -1. Read `.bear/agent/BEAR_AGENT.md`. -2. Follow `.bear/agent/BEAR_AGENT.md` for the full session. +1. Read `.bear/agent/BOOTSTRAP.md`. +2. Follow `.bear/agent/BOOTSTRAP.md` for the full session. Safety (mandatory before cleanup/delete commands): 1. Read `doc/SAFETY_RULES.md`. 2. Do not run ad-hoc recursive deletes. 3. Use `scripts/safe-clean-temp.ps1` for temp cleanup. + diff --git a/README.md b/README.md index 68b9d83..a512b8a 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ CI demo: - `scenario/02-feature-extension -> baseline/greenfield-output` Product specification: -- `doc/SPEC.md` +- `spec/SPEC.md` Branch context: -- Branch role: demo main/spec-only base -- Use this branch as the PR target for the greenfield baseline review. +- Branch role: greenfield implementation baseline +- Cut from: `scenario/01-agent-greenfield-implementation` +- Use this governance base for `pr-check`: `origin/main` diff --git a/bear-ir/account.bear.yaml b/bear-ir/account.bear.yaml new file mode 100644 index 0000000..88721e7 --- /dev/null +++ b/bear-ir/account.bear.yaml @@ -0,0 +1,122 @@ +version: v1 +block: + name: Account + kind: logic + operations: + - name: CreateAccount + contract: + inputs: + - name: ownerId + type: string + outputs: + - name: accountId + type: string + uses: + allow: + - port: accountStore + kind: external + ops: [create] + - name: Deposit + contract: + inputs: + - name: accountId + type: string + - name: amountCents + type: int + - name: requestId + type: string + outputs: + - name: balanceCents + type: int + - name: txSeq + type: int + uses: + allow: + - port: accountStore + kind: external + ops: [get, put] + - port: transactionLog + kind: block + targetOps: [AppendTransaction] + - port: idempotency + kind: external + ops: [get, put] + idempotency: + mode: use + keyFromInputs: [accountId, requestId] + invariants: + - kind: non_negative + field: balanceCents + - kind: non_negative + field: txSeq + - name: Withdraw + contract: + inputs: + - name: accountId + type: string + - name: amountCents + type: int + - name: requestId + type: string + outputs: + - name: balanceCents + type: int + - name: txSeq + type: int + uses: + allow: + - port: accountStore + kind: external + ops: [get, put] + - port: transactionLog + kind: block + targetOps: [AppendTransaction] + - port: idempotency + kind: external + ops: [get, put] + idempotency: + mode: use + keyFromInputs: [accountId, requestId] + invariants: + - kind: non_negative + field: balanceCents + - kind: non_negative + field: txSeq + - name: GetBalance + contract: + inputs: + - name: accountId + type: string + outputs: + - name: balanceCents + type: int + uses: + allow: + - port: accountStore + kind: external + ops: [get] + invariants: + - kind: non_negative + field: balanceCents + effects: + allow: + - port: accountStore + kind: external + ops: [create, get, put] + - port: transactionLog + kind: block + targetBlock: transaction-log + targetOps: [AppendTransaction] + - port: idempotency + kind: external + ops: [get, put] + idempotency: + store: + port: idempotency + getOp: get + putOp: put + invariants: + - kind: non_negative + field: balanceCents + - kind: non_negative + field: txSeq \ No newline at end of file diff --git a/bear-ir/transaction-log.bear.yaml b/bear-ir/transaction-log.bear.yaml new file mode 100644 index 0000000..ff5ec25 --- /dev/null +++ b/bear-ir/transaction-log.bear.yaml @@ -0,0 +1,52 @@ +version: v1 +block: + name: TransactionLog + kind: logic + operations: + - name: AppendTransaction + contract: + inputs: + - name: accountId + type: string + - name: type + type: enum + - name: requestId + type: string + - name: amountCents + type: int + - name: balanceAfterCents + type: int + outputs: + - name: txSeq + type: int + uses: + allow: + - port: transactionStore + kind: external + ops: [append] + invariants: + - kind: non_negative + field: txSeq + - name: GetTransactions + contract: + inputs: + - name: accountId + type: string + - name: sinceSeq + type: int + outputs: + - name: transactionsJson + type: string + uses: + allow: + - port: transactionStore + kind: external + ops: [list] + effects: + allow: + - port: transactionStore + kind: external + ops: [append, list] + invariants: + - kind: non_negative + field: txSeq \ No newline at end of file diff --git a/bear.blocks.yaml b/bear.blocks.yaml new file mode 100644 index 0000000..5eea8de --- /dev/null +++ b/bear.blocks.yaml @@ -0,0 +1,10 @@ +version: v1 +blocks: + - name: account + ir: bear-ir/account.bear.yaml + projectRoot: . + enabled: true + - name: transaction-log + ir: bear-ir/transaction-log.bear.yaml + projectRoot: . + enabled: true \ No newline at end of file diff --git a/doc/SPEC.md b/doc/SPEC.md deleted file mode 100644 index 1dead3a..0000000 --- a/doc/SPEC.md +++ /dev/null @@ -1,101 +0,0 @@ -# Product Spec: Wallet Service (BEAR Demo) - -## Goal -Build a small backend service that manages wallets and simple money movement. - -## Delivery constraints - -In-memory only. - -No external database. - -No message bus. - -No authentication. - -Expose a minimal REST API. - -## Entities - -Wallet: walletId, ownerId, status - -Balance: integer cents - -Operation: seq, opId, requestId, type, amountCents, walletId, balanceCents - -## API contract - -### 1. Create wallet - -POST /wallets - -Input: ownerId - -Output: walletId - -### 2. Deposit (idempotent) - -POST /wallets/{walletId}/deposits - -Input: amountCents, requestId - -Rules: - -amountCents > 0 - -idempotent by (walletId, requestId) - -replay returns the exact same {opId, balanceCents} as the first successful call - -Output: opId, balanceCents - -### 3. Withdraw (idempotent + non-negative) - -POST /wallets/{walletId}/withdrawals - -Input: amountCents, requestId - -Rules: - -amountCents > 0 - -resulting balance must not be negative - -idempotent by (walletId, requestId) - -replay returns the exact same {opId, balanceCents} as the first successful call - -Output: opId, balanceCents - -### 4. Get balance - -GET /wallets/{walletId}/balance - -Output: balanceCents - -### 5. Get statement - -GET /wallets/{walletId}/statement?sinceSeq= - -Output: entries ordered by seq ascending - -Each entry contains: seq, opId, requestId, type, amountCents, balanceCents - -## Ordering and identifiers - -The service assigns each operation a monotonically increasing seq. - -Statement ordering is defined by seq. - -opId is service-generated and unique within a single run. - -## Error expectations - -invalid amount => 400 validation error - -missing wallet => 404 not found - -insufficient funds => 409 domain error - -idempotent replay => 200 with the same successful payload as the original request - diff --git a/spec/SPEC.md b/spec/SPEC.md new file mode 100644 index 0000000..be91b2f --- /dev/null +++ b/spec/SPEC.md @@ -0,0 +1,107 @@ +# Minimal Spec: Account + Transaction Log + +## Goal + +Implement a tiny bank-account service with two domains: + +1. Account domain: owns balance and validates business rules. +2. Transaction Log domain: immutable append-only log of account operations. + +Transaction Log is not directly accessible for writes by any external API. Only the Account domain may append to it. + +## Delivery constraints + +- In-memory only. +- No external database. +- No message bus. +- No authentication/authorization. +- Expose a minimal REST API. + +## Data Model + +Account + +- accountId: string +- balanceCents: int (must be >= 0) + +Transaction + +- accountId: string +- seq: int (monotonic increasing per account, starting at 1) +- type: string ("DEPOSIT" | "WITHDRAW") +- requestId: string +- amountCents: int +- balanceAfterCents: int + +## External API + +### 1. Create account + +- POST /accounts +- Body: { "ownerId": "string" } +- Response 200: { "accountId": "string" } + +### 2. Deposit + +- POST /accounts/{accountId}/deposit +- Body: { "amountCents": int, "requestId": "string" } +- Response 200: { "balanceCents": int, "txSeq": int } +- Errors: + - 400 if amountCents <= 0 or missing requestId + - 404 if accountId not found + +### 3. Withdraw + +- POST /accounts/{accountId}/withdraw +- Body: { "amountCents": int, "requestId": "string" } +- Response 200: { "balanceCents": int, "txSeq": int } +- Errors: + - 400 if amountCents <= 0 or missing requestId + - 404 if accountId not found + - 409 if insufficient funds (would make balance negative) + +### 4. Get balance + +- GET /accounts/{accountId}/balance +- Response 200: { "balanceCents": int } +- Errors: + - 404 if accountId not found + +### 5. Get transactions + +- GET /accounts/{accountId}/transactions?sinceSeq= +- sinceSeq defaults to 0 if omitted +- Response 200: { "transactions": [ { "seq": int, "type": string, "requestId": string, "amountCents": int, "balanceAfterCents": int } ... ] } +- Semantics: return only transactions with seq > sinceSeq, in ascending seq order +- Errors: + - 400 if sinceSeq < 0 + - 404 if accountId not found + +## Core Rules + +Idempotency + +- Deposit and Withdraw are idempotent by (accountId, requestId). +- Replaying the same requestId for the same accountId must return exactly the same {balanceCents, txSeq} as the first successful execution. +- Idempotency applies only to successful operations. If the first attempt failed, a retry is treated as a normal new attempt. + +Transaction Log rules + +- Append-only. No updates/deletes. +- seq is assigned at append time and is strictly increasing per account. +- The Transaction Log append operation is internal-only: it can only be invoked from Account domain logic. No external endpoint may append directly. + +## Architecture Constraints (Domain-Level) + +- Account and Transaction Log are separate domain components with independent in-memory state. +- Account component owns balance state and business validation. +- Transaction Log component owns transaction storage and seq assignment. +- Account component must not write transaction storage directly; it may only call Transaction Log through an internal append operation. +- Transaction Log component must not read or modify Account balance state. +- No external API may append transactions directly. + +## Non-goals + +- No persistence requirements beyond "works in-memory for tests". +- No concurrency guarantees beyond deterministic behavior within a single process run. + diff --git a/src/main/java/blocks/account/adapter/InMemoryAccountStorePort.java b/src/main/java/blocks/account/adapter/InMemoryAccountStorePort.java new file mode 100644 index 0000000..3fb064a --- /dev/null +++ b/src/main/java/blocks/account/adapter/InMemoryAccountStorePort.java @@ -0,0 +1,60 @@ +package blocks.account.adapter; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.bear.generated.account.AccountStorePort; +import com.bear.generated.account.BearValue; + +public final class InMemoryAccountStorePort implements AccountStorePort { + private final Map accounts = new LinkedHashMap<>(); + + @Override + public BearValue create(BearValue input) { + String accountId = required(input, "accountId"); + AccountRecord record = new AccountRecord( + accountId, + input.get("ownerId"), + Integer.parseInt(required(input, "balanceCents"))); + accounts.put(accountId, record); + return toBearValue(record); + } + + @Override + public BearValue get(BearValue input) { + AccountRecord record = accounts.get(required(input, "accountId")); + return record == null ? BearValue.empty() : toBearValue(record); + } + + @Override + public BearValue put(BearValue input) { + String accountId = required(input, "accountId"); + AccountRecord record = new AccountRecord( + accountId, + input.get("ownerId"), + Integer.parseInt(required(input, "balanceCents"))); + accounts.put(accountId, record); + return toBearValue(record); + } + + private static BearValue toBearValue(AccountRecord record) { + BearValue.Builder builder = BearValue.builder() + .put("accountId", record.accountId()) + .put("balanceCents", Integer.toString(record.balanceCents())); + if (record.ownerId() != null) { + builder.put("ownerId", record.ownerId()); + } + return builder.build(); + } + + private static String required(BearValue input, String field) { + String value = input == null ? null : input.get(field); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + return value; + } + + private record AccountRecord(String accountId, String ownerId, int balanceCents) { + } +} \ No newline at end of file diff --git a/src/main/java/blocks/account/adapter/InMemoryIdempotencyPort.java b/src/main/java/blocks/account/adapter/InMemoryIdempotencyPort.java new file mode 100644 index 0000000..8f93cf5 --- /dev/null +++ b/src/main/java/blocks/account/adapter/InMemoryIdempotencyPort.java @@ -0,0 +1,40 @@ +package blocks.account.adapter; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.bear.generated.account.BearValue; +import com.bear.generated.account.IdempotencyPort; + +public final class InMemoryIdempotencyPort implements IdempotencyPort { + private final Map> entries = new LinkedHashMap<>(); + + @Override + public BearValue get(BearValue input) { + String key = required(input, "key"); + Map stored = entries.get(key); + if (stored == null) { + return BearValue.empty(); + } + BearValue.Builder builder = BearValue.builder(); + for (Map.Entry entry : stored.entrySet()) { + builder.put(entry.getKey(), entry.getValue()); + } + return builder.build(); + } + + @Override + public BearValue put(BearValue input) { + String key = required(input, "key"); + entries.put(key, new LinkedHashMap<>(input.asMap())); + return input; + } + + private static String required(BearValue input, String field) { + String value = input == null ? null : input.get(field); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + return value; + } +} \ No newline at end of file diff --git a/src/main/java/blocks/account/impl/AccountImpl.java b/src/main/java/blocks/account/impl/AccountImpl.java new file mode 100644 index 0000000..a35f18a --- /dev/null +++ b/src/main/java/blocks/account/impl/AccountImpl.java @@ -0,0 +1,126 @@ +package blocks.account.impl; + +import java.util.UUID; + +import com.bear.generated.account.AccountLogic; +import com.bear.generated.account.AccountStorePort; +import com.bear.generated.account.Account_CreateAccountRequest; +import com.bear.generated.account.Account_CreateAccountResult; +import com.bear.generated.account.Account_DepositRequest; +import com.bear.generated.account.Account_DepositResult; +import com.bear.generated.account.Account_GetBalanceRequest; +import com.bear.generated.account.Account_GetBalanceResult; +import com.bear.generated.account.Account_WithdrawRequest; +import com.bear.generated.account.Account_WithdrawResult; +import com.bear.generated.account.BearValue; +import com.bear.generated.account.TransactionLogPort; + +public final class AccountImpl implements AccountLogic { + @Override + public Account_CreateAccountResult executeCreateAccount(Account_CreateAccountRequest request, AccountStorePort accountStorePort) { + requireNonBlank(request.getOwnerId(), "ownerId"); + + String accountId = UUID.randomUUID().toString(); + accountStorePort.create(BearValue.builder() + .put("accountId", accountId) + .put("ownerId", request.getOwnerId()) + .put("balanceCents", "0") + .build()); + return new Account_CreateAccountResult(accountId); + } + + @Override + public Account_DepositResult executeDeposit(Account_DepositRequest request, AccountStorePort accountStorePort, TransactionLogPort transactionLogPort) { + int amountCents = requirePositiveAmount(request.getAmountCents()); + requireNonBlank(request.getRequestId(), "requestId"); + AccountState account = loadAccount(request.getAccountId(), accountStorePort); + + int balanceAfterCents = account.balanceCents() + amountCents; + accountStorePort.put(toAccountValue(account.accountId(), account.ownerId(), balanceAfterCents)); + int txSeq = appendTransaction(transactionLogPort, account.accountId(), "DEPOSIT", request.getRequestId(), amountCents, balanceAfterCents); + return new Account_DepositResult(balanceAfterCents, txSeq); + } + + @Override + public Account_GetBalanceResult executeGetBalance(Account_GetBalanceRequest request, AccountStorePort accountStorePort) { + AccountState account = loadAccount(request.getAccountId(), accountStorePort); + return new Account_GetBalanceResult(account.balanceCents()); + } + + @Override + public Account_WithdrawResult executeWithdraw(Account_WithdrawRequest request, AccountStorePort accountStorePort, TransactionLogPort transactionLogPort) { + int amountCents = requirePositiveAmount(request.getAmountCents()); + requireNonBlank(request.getRequestId(), "requestId"); + AccountState account = loadAccount(request.getAccountId(), accountStorePort); + if (account.balanceCents() < amountCents) { + throw new InsufficientFundsException(account.accountId()); + } + + int balanceAfterCents = account.balanceCents() - amountCents; + accountStorePort.put(toAccountValue(account.accountId(), account.ownerId(), balanceAfterCents)); + int txSeq = appendTransaction(transactionLogPort, account.accountId(), "WITHDRAW", request.getRequestId(), amountCents, balanceAfterCents); + return new Account_WithdrawResult(balanceAfterCents, txSeq); + } + + private static AccountState loadAccount(String accountId, AccountStorePort accountStorePort) { + requireNonBlank(accountId, "accountId"); + BearValue stored = accountStorePort.get(BearValue.builder().put("accountId", accountId).build()); + if (stored.get("accountId") == null) { + throw new AccountNotFoundException(accountId); + } + return new AccountState( + stored.get("accountId"), + stored.get("ownerId"), + parseInt(stored.get("balanceCents"), "balanceCents")); + } + + private static BearValue toAccountValue(String accountId, String ownerId, int balanceCents) { + return BearValue.builder() + .put("accountId", accountId) + .put("ownerId", ownerId) + .put("balanceCents", Integer.toString(balanceCents)) + .build(); + } + + private static int appendTransaction( + TransactionLogPort transactionLogPort, + String accountId, + String type, + String requestId, + int amountCents, + int balanceAfterCents + ) { + BearValue result = transactionLogPort.call(BearValue.builder() + .put("op", "AppendTransaction") + .put("accountId", accountId) + .put("type", type) + .put("requestId", requestId) + .put("amountCents", Integer.toString(amountCents)) + .put("balanceAfterCents", Integer.toString(balanceAfterCents)) + .build()); + return parseInt(result.get("txSeq"), "txSeq"); + } + + private static int requirePositiveAmount(Integer amountCents) { + if (amountCents == null || amountCents <= 0) { + throw new IllegalArgumentException("amountCents must be > 0"); + } + return amountCents; + } + + private static void requireNonBlank(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + } + + private static int parseInt(String raw, String field) { + if (raw == null || raw.isBlank()) { + throw new IllegalStateException(field + " is missing"); + } + return Integer.parseInt(raw); + } + + private record AccountState(String accountId, String ownerId, int balanceCents) { + } +} \ No newline at end of file diff --git a/src/main/java/blocks/account/impl/AccountNotFoundException.java b/src/main/java/blocks/account/impl/AccountNotFoundException.java new file mode 100644 index 0000000..6c5d618 --- /dev/null +++ b/src/main/java/blocks/account/impl/AccountNotFoundException.java @@ -0,0 +1,7 @@ +package blocks.account.impl; + +public final class AccountNotFoundException extends RuntimeException { + public AccountNotFoundException(String accountId) { + super("account not found: " + accountId); + } +} \ No newline at end of file diff --git a/src/main/java/blocks/account/impl/InsufficientFundsException.java b/src/main/java/blocks/account/impl/InsufficientFundsException.java new file mode 100644 index 0000000..248622d --- /dev/null +++ b/src/main/java/blocks/account/impl/InsufficientFundsException.java @@ -0,0 +1,7 @@ +package blocks.account.impl; + +public final class InsufficientFundsException extends RuntimeException { + public InsufficientFundsException(String accountId) { + super("insufficient funds for account: " + accountId); + } +} \ No newline at end of file diff --git a/src/main/java/blocks/transaction/log/adapter/InMemoryTransactionStorePort.java b/src/main/java/blocks/transaction/log/adapter/InMemoryTransactionStorePort.java new file mode 100644 index 0000000..0da1619 --- /dev/null +++ b/src/main/java/blocks/transaction/log/adapter/InMemoryTransactionStorePort.java @@ -0,0 +1,69 @@ +package blocks.transaction.log.adapter; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.bear.generated.transaction.log.BearValue; +import com.bear.generated.transaction.log.TransactionStorePort; + +public final class InMemoryTransactionStorePort implements TransactionStorePort { + private final Map> transactionsByAccount = new LinkedHashMap<>(); + + @Override + public BearValue append(BearValue input) { + String accountId = required(input, "accountId"); + List transactions = transactionsByAccount.computeIfAbsent(accountId, ignored -> new ArrayList<>()); + int nextSeq = transactions.size() + 1; + transactions.add(new TransactionRecord( + nextSeq, + required(input, "type"), + required(input, "requestId"), + Integer.parseInt(required(input, "amountCents")), + Integer.parseInt(required(input, "balanceAfterCents")))); + return BearValue.builder().put("txSeq", Integer.toString(nextSeq)).build(); + } + + @Override + public BearValue list(BearValue input) { + String accountId = required(input, "accountId"); + int sinceSeq = Integer.parseInt(required(input, "sinceSeq")); + List transactions = transactionsByAccount.getOrDefault(accountId, List.of()); + StringBuilder json = new StringBuilder("["); + boolean first = true; + for (TransactionRecord transaction : transactions) { + if (transaction.seq() <= sinceSeq) { + continue; + } + if (!first) { + json.append(','); + } + first = false; + json.append('{') + .append("\"seq\":").append(transaction.seq()) + .append(",\"type\":\"").append(escape(transaction.type())).append('\"') + .append(",\"requestId\":\"").append(escape(transaction.requestId())).append('\"') + .append(",\"amountCents\":").append(transaction.amountCents()) + .append(",\"balanceAfterCents\":").append(transaction.balanceAfterCents()) + .append('}'); + } + json.append(']'); + return BearValue.builder().put("transactionsJson", json.toString()).build(); + } + + private static String required(BearValue input, String field) { + String value = input == null ? null : input.get(field); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + return value; + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private record TransactionRecord(int seq, String type, String requestId, int amountCents, int balanceAfterCents) { + } +} \ No newline at end of file diff --git a/src/main/java/blocks/transaction/log/impl/TransactionLogImpl.java b/src/main/java/blocks/transaction/log/impl/TransactionLogImpl.java new file mode 100644 index 0000000..8e98ef0 --- /dev/null +++ b/src/main/java/blocks/transaction/log/impl/TransactionLogImpl.java @@ -0,0 +1,64 @@ +package blocks.transaction.log.impl; + +import com.bear.generated.transaction.log.BearValue; +import com.bear.generated.transaction.log.TransactionLogLogic; +import com.bear.generated.transaction.log.TransactionLog_AppendTransactionRequest; +import com.bear.generated.transaction.log.TransactionLog_AppendTransactionResult; +import com.bear.generated.transaction.log.TransactionLog_GetTransactionsRequest; +import com.bear.generated.transaction.log.TransactionLog_GetTransactionsResult; +import com.bear.generated.transaction.log.TransactionStorePort; + +public final class TransactionLogImpl implements TransactionLogLogic { + @Override + public TransactionLog_AppendTransactionResult executeAppendTransaction(TransactionLog_AppendTransactionRequest request, TransactionStorePort transactionStorePort) { + requireNonBlank(request.getAccountId(), "accountId"); + requireNonBlank(request.getType(), "type"); + requireNonBlank(request.getRequestId(), "requestId"); + if (request.getAmountCents() == null || request.getAmountCents() <= 0) { + throw new IllegalArgumentException("amountCents must be > 0"); + } + if (request.getBalanceAfterCents() == null || request.getBalanceAfterCents() < 0) { + throw new IllegalArgumentException("balanceAfterCents must be >= 0"); + } + + BearValue stored = transactionStorePort.append(BearValue.builder() + .put("accountId", request.getAccountId()) + .put("type", request.getType()) + .put("requestId", request.getRequestId()) + .put("amountCents", Integer.toString(request.getAmountCents())) + .put("balanceAfterCents", Integer.toString(request.getBalanceAfterCents())) + .build()); + return new TransactionLog_AppendTransactionResult(parseInt(stored.get("txSeq"), "txSeq")); + } + + @Override + public TransactionLog_GetTransactionsResult executeGetTransactions(TransactionLog_GetTransactionsRequest request, TransactionStorePort transactionStorePort) { + requireNonBlank(request.getAccountId(), "accountId"); + if (request.getSinceSeq() == null || request.getSinceSeq() < 0) { + throw new IllegalArgumentException("sinceSeq must be >= 0"); + } + + BearValue listed = transactionStorePort.list(BearValue.builder() + .put("accountId", request.getAccountId()) + .put("sinceSeq", Integer.toString(request.getSinceSeq())) + .build()); + return new TransactionLog_GetTransactionsResult(defaultJson(listed.get("transactionsJson"))); + } + + private static void requireNonBlank(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + } + + private static int parseInt(String raw, String field) { + if (raw == null || raw.isBlank()) { + throw new IllegalStateException(field + " is missing"); + } + return Integer.parseInt(raw); + } + + private static String defaultJson(String json) { + return json == null ? "[]" : json; + } +} \ No newline at end of file diff --git a/src/main/java/com/bear/account/demo/AccountApplication.java b/src/main/java/com/bear/account/demo/AccountApplication.java new file mode 100644 index 0000000..fba8047 --- /dev/null +++ b/src/main/java/com/bear/account/demo/AccountApplication.java @@ -0,0 +1,75 @@ +package com.bear.account.demo; + +import blocks.account.adapter.InMemoryAccountStorePort; +import blocks.account.adapter.InMemoryIdempotencyPort; +import blocks.account.impl.AccountNotFoundException; +import blocks.account.impl.InsufficientFundsException; +import blocks.transaction.log.adapter.InMemoryTransactionStorePort; + +import com.bear.generated.account.Account_CreateAccount; +import com.bear.generated.account.Account_CreateAccountRequest; +import com.bear.generated.account.Account_Deposit; +import com.bear.generated.account.Account_DepositRequest; +import com.bear.generated.account.Account_GetBalance; +import com.bear.generated.account.Account_GetBalanceRequest; +import com.bear.generated.account.Account_TransactionLogBlockClient; +import com.bear.generated.account.Account_Withdraw; +import com.bear.generated.account.Account_WithdrawRequest; +import com.bear.generated.account.TransactionLogPort; +import com.bear.generated.transaction.log.TransactionLog_AppendTransaction; +import com.bear.generated.transaction.log.TransactionLog_GetTransactions; +import com.bear.generated.transaction.log.TransactionLog_GetTransactionsRequest; + +public final class AccountApplication { + private final Account_CreateAccount createAccount; + private final Account_Deposit deposit; + private final Account_Withdraw withdraw; + private final Account_GetBalance getBalance; + private final TransactionLog_GetTransactions getTransactions; + + public AccountApplication() { + InMemoryAccountStorePort accountStorePort = new InMemoryAccountStorePort(); + InMemoryIdempotencyPort idempotencyPort = new InMemoryIdempotencyPort(); + InMemoryTransactionStorePort transactionStorePort = new InMemoryTransactionStorePort(); + TransactionLog_AppendTransaction appendTransaction = TransactionLog_AppendTransaction.of(transactionStorePort); + TransactionLogPort transactionLogPort = new Account_TransactionLogBlockClient(appendTransaction); + + this.createAccount = Account_CreateAccount.of(accountStorePort, idempotencyPort, transactionLogPort); + this.deposit = Account_Deposit.of(accountStorePort, idempotencyPort, transactionLogPort); + this.withdraw = Account_Withdraw.of(accountStorePort, idempotencyPort, transactionLogPort); + this.getBalance = Account_GetBalance.of(accountStorePort, idempotencyPort, transactionLogPort); + this.getTransactions = TransactionLog_GetTransactions.of(transactionStorePort); + } + + public String createAccount(String ownerId) { + return createAccount.execute(new Account_CreateAccountRequest(ownerId)).getAccountId(); + } + + public OperationResult deposit(String accountId, Integer amountCents, String requestId) { + return toOperationResult(deposit.execute(new Account_DepositRequest(accountId, amountCents, requestId))); + } + + public OperationResult withdraw(String accountId, Integer amountCents, String requestId) { + return toOperationResult(withdraw.execute(new Account_WithdrawRequest(accountId, amountCents, requestId))); + } + + public int getBalance(String accountId) { + return getBalance.execute(new Account_GetBalanceRequest(accountId)).getBalanceCents(); + } + + public String getTransactionsJson(String accountId, Integer sinceSeq) { + getBalance(accountId); + return getTransactions.execute(new TransactionLog_GetTransactionsRequest(accountId, sinceSeq)).getTransactionsJson(); + } + + private static OperationResult toOperationResult(com.bear.generated.account.Account_DepositResult result) { + return new OperationResult(result.getBalanceCents(), result.getTxSeq()); + } + + private static OperationResult toOperationResult(com.bear.generated.account.Account_WithdrawResult result) { + return new OperationResult(result.getBalanceCents(), result.getTxSeq()); + } + + public record OperationResult(int balanceCents, int txSeq) { + } +} \ No newline at end of file diff --git a/src/main/java/com/bear/account/demo/App.java b/src/main/java/com/bear/account/demo/App.java index bd26877..bb3bd8f 100644 --- a/src/main/java/com/bear/account/demo/App.java +++ b/src/main/java/com/bear/account/demo/App.java @@ -1,4 +1,151 @@ package com.bear.account.demo; -public class App { -} +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import blocks.account.impl.AccountNotFoundException; +import blocks.account.impl.InsufficientFundsException; + +public final class App { + private final AccountApplication application; + + public App(AccountApplication application) { + this.application = application; + } + + public static void main(String[] args) throws IOException { + HttpServer server = createServer(8080); + server.start(); + } + + public static HttpServer createServer(int port) throws IOException { + HttpServer server = HttpServer.create(new java.net.InetSocketAddress(port), 0); + App app = new App(new AccountApplication()); + server.createContext("/", app::handle); + return server; + } + + private void handle(HttpExchange exchange) throws IOException { + try { + String method = exchange.getRequestMethod(); + String path = exchange.getRequestURI().getPath(); + String[] segments = path.split("/"); + + if ("POST".equals(method) && "/accounts".equals(path)) { + handleCreateAccount(exchange); + return; + } + if (segments.length == 4 && "accounts".equals(segments[1]) && "balance".equals(segments[3]) && "GET".equals(method)) { + handleGetBalance(exchange, segments[2]); + return; + } + if (segments.length == 4 && "accounts".equals(segments[1]) && "deposit".equals(segments[3]) && "POST".equals(method)) { + handleDeposit(exchange, segments[2]); + return; + } + if (segments.length == 4 && "accounts".equals(segments[1]) && "withdraw".equals(segments[3]) && "POST".equals(method)) { + handleWithdraw(exchange, segments[2]); + return; + } + if (segments.length == 4 && "accounts".equals(segments[1]) && "transactions".equals(segments[3]) && "GET".equals(method)) { + handleGetTransactions(exchange, segments[2]); + return; + } + + sendJson(exchange, 404, "{\"error\":\"not found\"}"); + } catch (AccountNotFoundException e) { + sendJson(exchange, 404, "{\"error\":\"account not found\"}"); + } catch (InsufficientFundsException e) { + sendJson(exchange, 409, "{\"error\":\"insufficient funds\"}"); + } catch (IllegalArgumentException e) { + sendJson(exchange, 400, "{\"error\":\"" + escape(e.getMessage()) + "\"}"); + } catch (Exception e) { + sendJson(exchange, 500, "{\"error\":\"internal server error\"}"); + } finally { + exchange.close(); + } + } + + private void handleCreateAccount(HttpExchange exchange) throws IOException { + String body = readBody(exchange); + String ownerId = requireString(body, "ownerId"); + String accountId = application.createAccount(ownerId); + sendJson(exchange, 200, "{\"accountId\":\"" + escape(accountId) + "\"}"); + } + + private void handleDeposit(HttpExchange exchange, String accountId) throws IOException { + String body = readBody(exchange); + AccountApplication.OperationResult result = application.deposit(accountId, requireInt(body, "amountCents"), requireString(body, "requestId")); + sendJson(exchange, 200, "{\"balanceCents\":" + result.balanceCents() + ",\"txSeq\":" + result.txSeq() + "}"); + } + + private void handleWithdraw(HttpExchange exchange, String accountId) throws IOException { + String body = readBody(exchange); + AccountApplication.OperationResult result = application.withdraw(accountId, requireInt(body, "amountCents"), requireString(body, "requestId")); + sendJson(exchange, 200, "{\"balanceCents\":" + result.balanceCents() + ",\"txSeq\":" + result.txSeq() + "}"); + } + + private void handleGetBalance(HttpExchange exchange, String accountId) throws IOException { + int balanceCents = application.getBalance(accountId); + sendJson(exchange, 200, "{\"balanceCents\":" + balanceCents + "}"); + } + + private void handleGetTransactions(HttpExchange exchange, String accountId) throws IOException { + int sinceSeq = readSinceSeq(exchange.getRequestURI().getRawQuery()); + String transactionsJson = application.getTransactionsJson(accountId, sinceSeq); + sendJson(exchange, 200, "{\"transactions\":" + transactionsJson + "}"); + } + + private static int readSinceSeq(String rawQuery) { + if (rawQuery == null || rawQuery.isBlank()) { + return 0; + } + for (String part : rawQuery.split("&")) { + String[] pair = part.split("=", 2); + if (pair.length == 2 && "sinceSeq".equals(pair[0])) { + int value = Integer.parseInt(pair[1]); + if (value < 0) { + throw new IllegalArgumentException("sinceSeq must be >= 0"); + } + return value; + } + } + return 0; + } + + private static String readBody(HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + + private static void sendJson(HttpExchange exchange, int status, String body) throws IOException { + byte[] response = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, response.length); + exchange.getResponseBody().write(response); + } + + private static String requireString(String body, String field) { + Matcher matcher = Pattern.compile("\"" + field + "\"\\s*:\\s*\"([^\"]*)\"").matcher(body); + if (!matcher.find() || matcher.group(1).isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + return matcher.group(1); + } + + private static Integer requireInt(String body, String field) { + Matcher matcher = Pattern.compile("\"" + field + "\"\\s*:\\s*(-?\\d+)").matcher(body); + if (!matcher.find()) { + throw new IllegalArgumentException(field + " is required"); + } + return Integer.valueOf(matcher.group(1)); + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} \ No newline at end of file diff --git a/src/test/java/com/bear/account/demo/AppTest.java b/src/test/java/com/bear/account/demo/AppTest.java index 3b556ad..a8338e9 100644 --- a/src/test/java/com/bear/account/demo/AppTest.java +++ b/src/test/java/com/bear/account/demo/AppTest.java @@ -1,9 +1,138 @@ package com.bear.account.demo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import com.sun.net.httpserver.HttpServer; + class AppTest { + private HttpServer server; + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + } + @Test - void placeholder() { + void accountLifecycleAndTransactionFiltering() throws Exception { + server = App.createServer(0); + server.start(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = "http://localhost:" + server.getAddress().getPort(); + + HttpResponse create = send(client, "POST", baseUrl + "/accounts", "{\"ownerId\":\"owner-1\"}"); + assertEquals(200, create.statusCode()); + String accountId = extractString(create.body(), "accountId"); + + HttpResponse deposit = send(client, "POST", baseUrl + "/accounts/" + accountId + "/deposit", "{\"amountCents\":500,\"requestId\":\"req-1\"}"); + assertEquals(200, deposit.statusCode()); + assertEquals(500, extractInt(deposit.body(), "balanceCents")); + assertEquals(1, extractInt(deposit.body(), "txSeq")); + + HttpResponse withdraw = send(client, "POST", baseUrl + "/accounts/" + accountId + "/withdraw", "{\"amountCents\":125,\"requestId\":\"req-2\"}"); + assertEquals(200, withdraw.statusCode()); + assertEquals(375, extractInt(withdraw.body(), "balanceCents")); + assertEquals(2, extractInt(withdraw.body(), "txSeq")); + + HttpResponse balance = send(client, "GET", baseUrl + "/accounts/" + accountId + "/balance", null); + assertEquals(200, balance.statusCode()); + assertEquals(375, extractInt(balance.body(), "balanceCents")); + + HttpResponse transactions = send(client, "GET", baseUrl + "/accounts/" + accountId + "/transactions?sinceSeq=1", null); + assertEquals(200, transactions.statusCode()); + assertEquals(1, countOccurrences(transactions.body(), "\"seq\":")); + assertEquals(2, extractInt(transactions.body(), "seq")); + assertEquals("WITHDRAW", extractString(transactions.body(), "type")); + } + + @Test + void successfulOperationsAreIdempotentButFailuresAreNotSticky() throws Exception { + server = App.createServer(0); + server.start(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = "http://localhost:" + server.getAddress().getPort(); + + String accountId = extractString(send(client, "POST", baseUrl + "/accounts", "{\"ownerId\":\"owner-2\"}").body(), "accountId"); + + HttpResponse firstDeposit = send(client, "POST", baseUrl + "/accounts/" + accountId + "/deposit", "{\"amountCents\":300,\"requestId\":\"same-deposit\"}"); + HttpResponse replayDeposit = send(client, "POST", baseUrl + "/accounts/" + accountId + "/deposit", "{\"amountCents\":300,\"requestId\":\"same-deposit\"}"); + assertEquals(200, firstDeposit.statusCode()); + assertEquals(firstDeposit.body(), replayDeposit.body()); + + HttpResponse failedWithdraw = send(client, "POST", baseUrl + "/accounts/" + accountId + "/withdraw", "{\"amountCents\":500,\"requestId\":\"same-withdraw\"}"); + assertEquals(409, failedWithdraw.statusCode()); + + HttpResponse secondDeposit = send(client, "POST", baseUrl + "/accounts/" + accountId + "/deposit", "{\"amountCents\":400,\"requestId\":\"top-up\"}"); + assertEquals(200, secondDeposit.statusCode()); + + HttpResponse retriedWithdraw = send(client, "POST", baseUrl + "/accounts/" + accountId + "/withdraw", "{\"amountCents\":500,\"requestId\":\"same-withdraw\"}"); + assertEquals(200, retriedWithdraw.statusCode()); + assertEquals(200, extractInt(retriedWithdraw.body(), "balanceCents")); + assertEquals(3, extractInt(retriedWithdraw.body(), "txSeq")); + } + + @Test + void validationAndNotFoundErrorsMatchSpec() throws Exception { + server = App.createServer(0); + server.start(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = "http://localhost:" + server.getAddress().getPort(); + + HttpResponse missingRequestId = send(client, "POST", baseUrl + "/accounts/missing/deposit", "{\"amountCents\":100}"); + assertEquals(400, missingRequestId.statusCode()); + + HttpResponse missingAccount = send(client, "GET", baseUrl + "/accounts/missing/balance", null); + assertEquals(404, missingAccount.statusCode()); + + String accountId = extractString(send(client, "POST", baseUrl + "/accounts", "{\"ownerId\":\"owner-3\"}").body(), "accountId"); + HttpResponse invalidSince = send(client, "GET", baseUrl + "/accounts/" + accountId + "/transactions?sinceSeq=-1", null); + assertEquals(400, invalidSince.statusCode()); + } + + private static HttpResponse send(HttpClient client, String method, String url, String body) throws IOException, InterruptedException { + HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)); + if (body == null) { + builder.method(method, HttpRequest.BodyPublishers.noBody()); + } else { + builder.header("Content-Type", "application/json"); + builder.method(method, HttpRequest.BodyPublishers.ofString(body)); + } + return client.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private static String extractString(String json, String field) { + java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\"" + field + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json); + if (!matcher.find()) { + throw new AssertionError("missing field: " + field + " in " + json); + } + return matcher.group(1); + } + + private static int extractInt(String json, String field) { + java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\"" + field + "\"\\s*:\\s*(-?\\d+)").matcher(json); + if (!matcher.find()) { + throw new AssertionError("missing field: " + field + " in " + json); + } + return Integer.parseInt(matcher.group(1)); + } + + private static int countOccurrences(String text, String token) { + int count = 0; + int index = 0; + while ((index = text.indexOf(token, index)) >= 0) { + count++; + index += token.length(); + } + return count; } -} +} \ No newline at end of file