Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31bcc1cc82 | |||
| 1166e8c5d3 | |||
| 8e94c232b4 | |||
| b00c52baa3 | |||
| 0dc350d534 | |||
| ac565e4b85 | |||
| 0808e027a5 | |||
| fc48b37ee7 | |||
| 026467c6db | |||
| e852c773e7 | |||
| 69a4d5a084 | |||
| e13a81e31e | |||
| 0624795370 | |||
| e4a77fdea3 | |||
| 22e8717e0c | |||
| 8a187b45b9 | |||
| 47e8f80572 | |||
| ebfef1b783 | |||
| ed98957dfe | |||
| c0f7954fee | |||
| c987827f76 | |||
| 6058aa6150 | |||
| 28113f38e6 | |||
| d883b76c0d | |||
| 0ad82e60ac | |||
| 40ceecda76 | |||
| fca3190eb1 | |||
| c210978261 | |||
| a61c67614f | |||
| 27425889b3 | |||
| 61d923d5be | |||
| 87eb2703f5 | |||
| bc0f5173c0 |
234
.gitea/workflows/pipeline.yml
Normal file
234
.gitea/workflows/pipeline.yml
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
name: Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
# ── UI ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ui-test:
|
||||||
|
name: UI Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:22-bullseye
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Firefox
|
||||||
|
run: apt-get update -qq && apt-get install -y --no-install-recommends firefox-esr
|
||||||
|
|
||||||
|
- name: Cache node_modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ui/node_modules
|
||||||
|
key: npm-${{ hashFiles('ui/package-lock.json') }}
|
||||||
|
restore-keys: npm-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: ui
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: FIREFOX_BIN=/usr/bin/firefox-esr npx ng test --watch=false
|
||||||
|
working-directory: ui
|
||||||
|
|
||||||
|
ui-lint:
|
||||||
|
name: UI Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:22-bullseye
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache node_modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ui/node_modules
|
||||||
|
key: npm-${{ hashFiles('ui/package-lock.json') }}
|
||||||
|
restore-keys: npm-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: ui
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: ui
|
||||||
|
|
||||||
|
# ── API ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
api-unit:
|
||||||
|
name: API Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/astral-sh/uv:python3.12-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Install Node (for JS actions)
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends nodejs ca-certificates curl
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache uv store
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: /root/.cache/uv
|
||||||
|
key: uv-${{ hashFiles('api/uv.lock') }}
|
||||||
|
restore-keys: uv-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --group dev
|
||||||
|
working-directory: api
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: uv run pytest tests/unit/ -q
|
||||||
|
working-directory: api
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://u:p@localhost/db
|
||||||
|
S3_ENDPOINT_URL: http://localhost:9000
|
||||||
|
S3_BUCKET_NAME: test
|
||||||
|
S3_ACCESS_KEY_ID: key
|
||||||
|
S3_SECRET_ACCESS_KEY: secret
|
||||||
|
S3_REGION: us-east-1
|
||||||
|
API_BASE_URL: http://localhost:8000
|
||||||
|
JWT_SECRET_KEY: d34db33fc4f3b00bd34db33fc4f3b00b
|
||||||
|
OWNER_USERNAME: testowner
|
||||||
|
OWNER_PASSWORD: testpassword
|
||||||
|
|
||||||
|
api-lint:
|
||||||
|
name: API Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/astral-sh/uv:python3.12-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Install Node (for JS actions)
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends nodejs ca-certificates curl
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run Ruff
|
||||||
|
run: uvx ruff check .
|
||||||
|
working-directory: api
|
||||||
|
|
||||||
|
api-integration:
|
||||||
|
name: API Integration Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/astral-sh/uv:python3.12-bookworm
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: reactbin
|
||||||
|
POSTGRES_PASSWORD: reactbin
|
||||||
|
POSTGRES_DB: reactbin_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U reactbin -d reactbin_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
minio:
|
||||||
|
image: bitnamilegacy/minio:2025.7.23-debian-12-r5
|
||||||
|
env:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
MINIO_DEFAULT_BUCKETS: reactbin-test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "mc ready local || exit 1"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
steps:
|
||||||
|
- name: Install Node and curl (for JS actions and mc)
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends nodejs ca-certificates curl
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache uv store
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: /root/.cache/uv
|
||||||
|
key: uv-${{ hashFiles('api/uv.lock') }}
|
||||||
|
restore-keys: uv-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --group dev
|
||||||
|
working-directory: api
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
run: uv run pytest tests/integration/ -q
|
||||||
|
working-directory: api
|
||||||
|
env:
|
||||||
|
TEST_DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres/reactbin_test
|
||||||
|
DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres/reactbin_test
|
||||||
|
S3_ENDPOINT_URL: http://minio:9000
|
||||||
|
S3_BUCKET_NAME: reactbin-test
|
||||||
|
S3_ACCESS_KEY_ID: minioadmin
|
||||||
|
S3_SECRET_ACCESS_KEY: minioadmin
|
||||||
|
S3_REGION: us-east-1
|
||||||
|
API_BASE_URL: http://localhost:8000
|
||||||
|
JWT_SECRET_KEY: d34db33fc4f3b00bd34db33fc4f3b00b
|
||||||
|
OWNER_USERNAME: testowner
|
||||||
|
OWNER_PASSWORD: testpassword
|
||||||
|
|
||||||
|
# ── Image builds (tag-only, gated on all jobs) ────────────────────────────────
|
||||||
|
|
||||||
|
build-api:
|
||||||
|
name: Build & Push API Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [ui-test, ui-lint, api-unit, api-lint, api-integration]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./api
|
||||||
|
file: ./api/Dockerfile.prod
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-api:${{ github.ref_name }}
|
||||||
|
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-api:latest
|
||||||
|
|
||||||
|
build-ui:
|
||||||
|
name: Build & Push UI Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [ui-test, ui-lint, api-unit, api-lint, api-integration]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./ui
|
||||||
|
file: ./ui/Dockerfile.prod
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-ui:${{ github.ref_name }}
|
||||||
|
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-ui:latest
|
||||||
@@ -18,6 +18,13 @@ hooks:
|
|||||||
prompt: Execute speckit.git.feature?
|
prompt: Execute speckit.git.feature?
|
||||||
description: Create feature branch before specification
|
description: Create feature branch before specification
|
||||||
condition: null
|
condition: null
|
||||||
|
- extension: memory-loader
|
||||||
|
command: speckit.memory-loader.load
|
||||||
|
enabled: true
|
||||||
|
optional: false
|
||||||
|
prompt: Execute speckit.memory-loader.load?
|
||||||
|
description: Load project memory files before specification
|
||||||
|
condition: null
|
||||||
before_clarify:
|
before_clarify:
|
||||||
- extension: git
|
- extension: git
|
||||||
command: speckit.git.commit
|
command: speckit.git.commit
|
||||||
@@ -26,6 +33,13 @@ hooks:
|
|||||||
prompt: Commit outstanding changes before clarification?
|
prompt: Commit outstanding changes before clarification?
|
||||||
description: Auto-commit before spec clarification
|
description: Auto-commit before spec clarification
|
||||||
condition: null
|
condition: null
|
||||||
|
- extension: memory-loader
|
||||||
|
command: speckit.memory-loader.load
|
||||||
|
enabled: true
|
||||||
|
optional: false
|
||||||
|
prompt: Execute speckit.memory-loader.load?
|
||||||
|
description: Load project memory files before clarification
|
||||||
|
condition: null
|
||||||
before_plan:
|
before_plan:
|
||||||
- extension: git
|
- extension: git
|
||||||
command: speckit.git.commit
|
command: speckit.git.commit
|
||||||
@@ -34,6 +48,13 @@ hooks:
|
|||||||
prompt: Commit outstanding changes before planning?
|
prompt: Commit outstanding changes before planning?
|
||||||
description: Auto-commit before implementation planning
|
description: Auto-commit before implementation planning
|
||||||
condition: null
|
condition: null
|
||||||
|
- extension: memory-loader
|
||||||
|
command: speckit.memory-loader.load
|
||||||
|
enabled: true
|
||||||
|
optional: false
|
||||||
|
prompt: Execute speckit.memory-loader.load?
|
||||||
|
description: Load project memory files before planning
|
||||||
|
condition: null
|
||||||
before_tasks:
|
before_tasks:
|
||||||
- extension: git
|
- extension: git
|
||||||
command: speckit.git.commit
|
command: speckit.git.commit
|
||||||
@@ -42,6 +63,13 @@ hooks:
|
|||||||
prompt: Commit outstanding changes before task generation?
|
prompt: Commit outstanding changes before task generation?
|
||||||
description: Auto-commit before task generation
|
description: Auto-commit before task generation
|
||||||
condition: null
|
condition: null
|
||||||
|
- extension: memory-loader
|
||||||
|
command: speckit.memory-loader.load
|
||||||
|
enabled: true
|
||||||
|
optional: false
|
||||||
|
prompt: Execute speckit.memory-loader.load?
|
||||||
|
description: Load project memory files before task generation
|
||||||
|
condition: null
|
||||||
before_implement:
|
before_implement:
|
||||||
- extension: git
|
- extension: git
|
||||||
command: speckit.git.commit
|
command: speckit.git.commit
|
||||||
@@ -50,6 +78,13 @@ hooks:
|
|||||||
prompt: Commit outstanding changes before implementation?
|
prompt: Commit outstanding changes before implementation?
|
||||||
description: Auto-commit before implementation
|
description: Auto-commit before implementation
|
||||||
condition: null
|
condition: null
|
||||||
|
- extension: memory-loader
|
||||||
|
command: speckit.memory-loader.load
|
||||||
|
enabled: true
|
||||||
|
optional: false
|
||||||
|
prompt: Execute speckit.memory-loader.load?
|
||||||
|
description: Load project memory files before implementation
|
||||||
|
condition: null
|
||||||
before_checklist:
|
before_checklist:
|
||||||
- extension: git
|
- extension: git
|
||||||
command: speckit.git.commit
|
command: speckit.git.commit
|
||||||
@@ -58,6 +93,13 @@ hooks:
|
|||||||
prompt: Commit outstanding changes before checklist?
|
prompt: Commit outstanding changes before checklist?
|
||||||
description: Auto-commit before checklist generation
|
description: Auto-commit before checklist generation
|
||||||
condition: null
|
condition: null
|
||||||
|
- extension: memory-loader
|
||||||
|
command: speckit.memory-loader.load
|
||||||
|
enabled: true
|
||||||
|
optional: false
|
||||||
|
prompt: Execute speckit.memory-loader.load?
|
||||||
|
description: Load project memory files before checklist generation
|
||||||
|
condition: null
|
||||||
before_analyze:
|
before_analyze:
|
||||||
- extension: git
|
- extension: git
|
||||||
command: speckit.git.commit
|
command: speckit.git.commit
|
||||||
@@ -66,6 +108,13 @@ hooks:
|
|||||||
prompt: Commit outstanding changes before analysis?
|
prompt: Commit outstanding changes before analysis?
|
||||||
description: Auto-commit before analysis
|
description: Auto-commit before analysis
|
||||||
condition: null
|
condition: null
|
||||||
|
- extension: memory-loader
|
||||||
|
command: speckit.memory-loader.load
|
||||||
|
enabled: true
|
||||||
|
optional: false
|
||||||
|
prompt: Execute speckit.memory-loader.load?
|
||||||
|
description: Load project memory files before analysis
|
||||||
|
condition: null
|
||||||
before_taskstoissues:
|
before_taskstoissues:
|
||||||
- extension: git
|
- extension: git
|
||||||
command: speckit.git.commit
|
command: speckit.git.commit
|
||||||
|
|||||||
@@ -18,6 +18,20 @@
|
|||||||
},
|
},
|
||||||
"registered_skills": [],
|
"registered_skills": [],
|
||||||
"installed_at": "2026-05-02T15:15:14.534434+00:00"
|
"installed_at": "2026-05-02T15:15:14.534434+00:00"
|
||||||
|
},
|
||||||
|
"memory-loader": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "local",
|
||||||
|
"manifest_hash": "sha256:d1caef45965accd4316d8aede0a4ac67f910017ea3c501814cfc7e2d8177ab0b",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 10,
|
||||||
|
"registered_commands": {
|
||||||
|
"claude": [
|
||||||
|
"speckit.memory-loader.load"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"registered_skills": [],
|
||||||
|
"installed_at": "2026-05-11T20:50:02.702659+00:00"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
.specify/extensions/memory-loader/CHANGELOG.md
Normal file
9
.specify/extensions/memory-loader/CHANGELOG.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release
|
||||||
|
- `speckit.memory-loader.load` command to read all `.specify/memory/*.md` files
|
||||||
|
- `before_*` hooks for specify, plan, tasks, implement, clarify, checklist, and analyze lifecycle commands
|
||||||
|
- Graceful degradation when memory directory is missing or files are unreadable
|
||||||
21
.specify/extensions/memory-loader/LICENSE
Normal file
21
.specify/extensions/memory-loader/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 KevinBrown5280
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
81
.specify/extensions/memory-loader/README.md
Normal file
81
.specify/extensions/memory-loader/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# spec-kit-memory-loader
|
||||||
|
|
||||||
|
A [Spec Kit](https://github.com/github/spec-kit) extension that loads `.specify/memory/` files before spec-kit lifecycle commands so LLM agents have project governance context (constitution, glossary, conventions, resource standards).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Spec-kit lifecycle commands (`/speckit.specify`, `/speckit.plan`, `/speckit.implement`, etc.) execute without awareness of project-specific governance documents stored in `.specify/memory/`. This means:
|
||||||
|
|
||||||
|
- Constitution principles are not consulted during specification
|
||||||
|
- Glossary terms are not available during planning
|
||||||
|
- Coding conventions are missed during implementation
|
||||||
|
- Resource standards are ignored during task generation
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
The Memory Loader extension registers `before_*` hooks on all major spec-kit lifecycle commands. Before each command runs, it reads every `.md` file from `.specify/memory/` and outputs their contents, giving the LLM agent full governance context.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From release
|
||||||
|
specify extension add memory-loader --from https://github.com/KevinBrown5280/spec-kit-memory-loader/archive/refs/tags/v1.0.0.zip
|
||||||
|
|
||||||
|
# From main branch
|
||||||
|
specify extension add memory-loader --from https://github.com/KevinBrown5280/spec-kit-memory-loader/archive/refs/heads/main.zip
|
||||||
|
|
||||||
|
# Development mode (local clone)
|
||||||
|
specify extension add --dev /path/to/spec-kit-memory-loader
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description | Modifies Files? |
|
||||||
|
|---------|-------------|-----------------|
|
||||||
|
| `speckit.memory-loader.load` | Read all project memory files and output their contents for context | No — read-only |
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Gather**: Reads every `.md` file from `.specify/memory/`
|
||||||
|
- If the directory does not exist, skips silently
|
||||||
|
- If a file cannot be read, skips it and continues
|
||||||
|
|
||||||
|
2. **Output**: For each file, prints a headed section:
|
||||||
|
```
|
||||||
|
## Memory: {filename}
|
||||||
|
|
||||||
|
{file contents}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Summarize**: After all files, outputs:
|
||||||
|
```
|
||||||
|
Context loaded: {memory_count} memory files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
The extension fires automatically before these lifecycle commands:
|
||||||
|
|
||||||
|
| Hook | Command | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `before_specify` | `speckit.memory-loader.load` | Load context before specification |
|
||||||
|
| `before_plan` | `speckit.memory-loader.load` | Load context before planning |
|
||||||
|
| `before_tasks` | `speckit.memory-loader.load` | Load context before task generation |
|
||||||
|
| `before_implement` | `speckit.memory-loader.load` | Load context before implementation |
|
||||||
|
| `before_clarify` | `speckit.memory-loader.load` | Load context before clarification |
|
||||||
|
| `before_checklist` | `speckit.memory-loader.load` | Load context before checklist generation |
|
||||||
|
| `before_analyze` | `speckit.memory-loader.load` | Load context before analysis |
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
- **Read-only** — never modifies any files
|
||||||
|
- **Graceful degradation** — missing directory or unreadable files are skipped silently
|
||||||
|
- **Governance only** — loads project-level memory; feature-specific reference docs are handled separately by a companion extension
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Spec Kit >= 0.6.0
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
description: "Read all project memory files and output their contents for LLM context"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Load Project Memory
|
||||||
|
|
||||||
|
Read ALL `.md` files in `.specify/memory/` and output their contents. This gives you project governance context (constitution, glossary, conventions, resource standards) for the command that follows.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Gather**: Read every `.md` file from `.specify/memory/`.
|
||||||
|
- If the directory does not exist, skip it silently.
|
||||||
|
- If a file cannot be read, skip it and continue.
|
||||||
|
|
||||||
|
2. **Output**: For each file, print a headed section:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Memory: {filename}
|
||||||
|
|
||||||
|
{file contents}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Summarize**: After all files, output:
|
||||||
|
|
||||||
|
```
|
||||||
|
Context loaded: {memory_count} memory files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Notes
|
||||||
|
|
||||||
|
- Designed as a mandatory `before_*` hook that fires before spec-kit lifecycle commands.
|
||||||
|
- Loads governance context only. Feature-specific reference docs are loaded by the `spec-reference-loader` extension.
|
||||||
|
- This is a read-only operation — do NOT modify any files.
|
||||||
61
.specify/extensions/memory-loader/extension.yml
Normal file
61
.specify/extensions/memory-loader/extension.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
schema_version: "1.0"
|
||||||
|
|
||||||
|
extension:
|
||||||
|
id: "memory-loader"
|
||||||
|
name: "Memory Loader"
|
||||||
|
version: "1.0.0"
|
||||||
|
description: "Loads .specify/memory/ files before spec-kit lifecycle commands so LLM agents have project governance context"
|
||||||
|
author: "KevinBrown5280"
|
||||||
|
repository: "https://github.com/KevinBrown5280/spec-kit-memory-loader"
|
||||||
|
license: "MIT"
|
||||||
|
homepage: "https://github.com/KevinBrown5280/spec-kit-memory-loader"
|
||||||
|
|
||||||
|
requires:
|
||||||
|
speckit_version: ">=0.6.0"
|
||||||
|
|
||||||
|
provides:
|
||||||
|
commands:
|
||||||
|
- name: "speckit.memory-loader.load"
|
||||||
|
file: "commands/speckit.memory-loader.load.md"
|
||||||
|
description: "Read all project memory files and output their contents for context"
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
before_specify:
|
||||||
|
command: "speckit.memory-loader.load"
|
||||||
|
optional: false
|
||||||
|
description: "Load project memory files before specification"
|
||||||
|
|
||||||
|
before_plan:
|
||||||
|
command: "speckit.memory-loader.load"
|
||||||
|
optional: false
|
||||||
|
description: "Load project memory files before planning"
|
||||||
|
|
||||||
|
before_tasks:
|
||||||
|
command: "speckit.memory-loader.load"
|
||||||
|
optional: false
|
||||||
|
description: "Load project memory files before task generation"
|
||||||
|
|
||||||
|
before_implement:
|
||||||
|
command: "speckit.memory-loader.load"
|
||||||
|
optional: false
|
||||||
|
description: "Load project memory files before implementation"
|
||||||
|
|
||||||
|
before_clarify:
|
||||||
|
command: "speckit.memory-loader.load"
|
||||||
|
optional: false
|
||||||
|
description: "Load project memory files before clarification"
|
||||||
|
|
||||||
|
before_checklist:
|
||||||
|
command: "speckit.memory-loader.load"
|
||||||
|
optional: false
|
||||||
|
description: "Load project memory files before checklist generation"
|
||||||
|
|
||||||
|
before_analyze:
|
||||||
|
command: "speckit.memory-loader.load"
|
||||||
|
optional: false
|
||||||
|
description: "Load project memory files before analysis"
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- "memory"
|
||||||
|
- "context"
|
||||||
|
- "governance"
|
||||||
@@ -1 +1 @@
|
|||||||
{"feature_directory":"specs/016-copy-url-toast"}
|
{"feature_directory":"specs/018-pagination-controls"}
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
"here": true,
|
"here": true,
|
||||||
"integration": "claude",
|
"integration": "claude",
|
||||||
"script": "sh",
|
"script": "sh",
|
||||||
"speckit_version": "0.8.2.dev0"
|
"speckit_version": "0.8.8"
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
{
|
{
|
||||||
|
"version": "0.8.8",
|
||||||
|
"integration_state_schema": 1,
|
||||||
|
"installed_integrations": [
|
||||||
|
"claude"
|
||||||
|
],
|
||||||
|
"integration_settings": {
|
||||||
|
"claude": {
|
||||||
|
"script": "sh",
|
||||||
|
"invoke_separator": "-"
|
||||||
|
}
|
||||||
|
},
|
||||||
"integration": "claude",
|
"integration": "claude",
|
||||||
"version": "0.8.2.dev0"
|
"default_integration": "claude"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"integration": "claude",
|
"integration": "claude",
|
||||||
"version": "0.8.2.dev0",
|
"version": "0.8.8",
|
||||||
"installed_at": "2026-05-02T15:15:14.461699+00:00",
|
"installed_at": "2026-05-11T20:40:51.902830+00:00",
|
||||||
"files": {
|
"files": {
|
||||||
".claude/skills/speckit-analyze/SKILL.md": "2eef0fbff6cad15c9d4714d8986192387811c971a82a1135ab0404f3db0c5e90",
|
".claude/skills/speckit-analyze/SKILL.md": "2eef0fbff6cad15c9d4714d8986192387811c971a82a1135ab0404f3db0c5e90",
|
||||||
".claude/skills/speckit-checklist/SKILL.md": "26419fc118dcd9c4e1e977460696a04b7757b8fb0a2d1ff9c64732669deb7977",
|
".claude/skills/speckit-checklist/SKILL.md": "26419fc118dcd9c4e1e977460696a04b7757b8fb0a2d1ff9c64732669deb7977",
|
||||||
".claude/skills/speckit-clarify/SKILL.md": "f2560f9f2007b4e995130f0c42633f08837a76a35d94e84091713a6f39bb1064",
|
".claude/skills/speckit-clarify/SKILL.md": "f2560f9f2007b4e995130f0c42633f08837a76a35d94e84091713a6f39bb1064",
|
||||||
".claude/skills/speckit-constitution/SKILL.md": "c1a044aba243ca6aff627fb5e4404feb6f1108d4f7dd174631bee3ae477d6c15",
|
".claude/skills/speckit-constitution/SKILL.md": "c1a044aba243ca6aff627fb5e4404feb6f1108d4f7dd174631bee3ae477d6c15",
|
||||||
".claude/skills/speckit-implement/SKILL.md": "da9b4d6f9894d300515c66c057cee74025b27f2238895e3c22b59c6266b5be74",
|
".claude/skills/speckit-implement/SKILL.md": "6029565c1a56de8919d1846b187cd644f734a0e30a6067a709803e6bc0d2abf7",
|
||||||
".claude/skills/speckit-plan/SKILL.md": "8141ebbce228ad0b422a84e3b995d2bd85de917b96eadd02b5fcb56fb23f2594",
|
".claude/skills/speckit-plan/SKILL.md": "8141ebbce228ad0b422a84e3b995d2bd85de917b96eadd02b5fcb56fb23f2594",
|
||||||
".claude/skills/speckit-specify/SKILL.md": "8599f8e2e3463de7d4f47591565340be2f775fd61b7dd9d2175503bc3b713b77",
|
".claude/skills/speckit-specify/SKILL.md": "caadc05119eca453709a0425ed88d253883f9c55da4c13a4898367653a859483",
|
||||||
".claude/skills/speckit-tasks/SKILL.md": "792589edf0ebf89af797c6bdda4e9d2c9938c696181d6f1484bf7a7cd090efaa",
|
".claude/skills/speckit-tasks/SKILL.md": "54c4665be61818ed50aa528bb4c51db3627079b2c67d47f2b01046268288c4a5",
|
||||||
".claude/skills/speckit-taskstoissues/SKILL.md": "99bf5ffd90dcb57b63007c7f659a5160a18ce6feb82889895808e2d277abe83b"
|
".claude/skills/speckit-taskstoissues/SKILL.md": "99bf5ffd90dcb57b63007c7f659a5160a18ce6feb82889895808e2d277abe83b"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"integration": "speckit",
|
"integration": "speckit",
|
||||||
"version": "0.8.2.dev0",
|
"version": "0.8.8",
|
||||||
"installed_at": "2026-05-02T15:15:14.478105+00:00",
|
"installed_at": "2026-05-02T15:15:14.478105+00:00",
|
||||||
"files": {
|
"files": {
|
||||||
".specify/scripts/bash/create-new-feature.sh": "bcf4964ca0c6c78717bb42d9e66b8c7e5ee82779cd96afc5aa7b08b75abe5790",
|
".specify/scripts/bash/create-new-feature.sh": "bcf4964ca0c6c78717bb42d9e66b8c7e5ee82779cd96afc5aa7b08b75abe5790",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
".specify/templates/plan-template.md": "5ad267630e370c73fe957dafa61bf76d633f3aea9d2f0b5195087d729cdd1e41",
|
".specify/templates/plan-template.md": "5ad267630e370c73fe957dafa61bf76d633f3aea9d2f0b5195087d729cdd1e41",
|
||||||
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
|
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
|
||||||
".specify/templates/spec-template.md": "785dc50d856dd92d6515eca0761e16dce0c9ba0a3cd07154fd33eae77932422a",
|
".specify/templates/spec-template.md": "785dc50d856dd92d6515eca0761e16dce0c9ba0a3cd07154fd33eae77932422a",
|
||||||
".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c"
|
".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c",
|
||||||
|
".specify/scripts/bash/setup-tasks.sh": "e8d050c63c5afb664a8b671b0b0155513fb9cab0567b335e16b9eb035482aad2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
96
.specify/scripts/bash/setup-tasks.sh
Executable file
96
.specify/scripts/bash/setup-tasks.sh
Executable file
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
JSON_MODE=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--json) JSON_MODE=true ;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--json]"
|
||||||
|
echo " --json Output results in JSON format"
|
||||||
|
echo " --help Show this help message"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Source common functions
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
|
# Get feature paths
|
||||||
|
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||||
|
eval "$_paths_output"
|
||||||
|
unset _paths_output
|
||||||
|
|
||||||
|
# Validate branch
|
||||||
|
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||||
|
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||||
|
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||||
|
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||||
|
echo "Run /speckit.plan first to create the implementation plan." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$FEATURE_SPEC" ]]; then
|
||||||
|
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
||||||
|
echo "Run /speckit.specify first to create the feature structure." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build available docs list
|
||||||
|
docs=()
|
||||||
|
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||||
|
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||||
|
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
|
||||||
|
docs+=("contracts/")
|
||||||
|
fi
|
||||||
|
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
||||||
|
|
||||||
|
# Resolve tasks template through override stack
|
||||||
|
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
|
||||||
|
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
|
||||||
|
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
|
||||||
|
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Output results
|
||||||
|
if $JSON_MODE; then
|
||||||
|
if has_jq; then
|
||||||
|
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||||
|
json_docs="[]"
|
||||||
|
else
|
||||||
|
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||||
|
fi
|
||||||
|
jq -cn \
|
||||||
|
--arg feature_dir "$FEATURE_DIR" \
|
||||||
|
--argjson docs "$json_docs" \
|
||||||
|
--arg tasks_template "${TASKS_TEMPLATE:-}" \
|
||||||
|
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}'
|
||||||
|
else
|
||||||
|
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||||
|
json_docs="[]"
|
||||||
|
else
|
||||||
|
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||||
|
json_docs="[${json_docs%,}]"
|
||||||
|
fi
|
||||||
|
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \
|
||||||
|
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||||
|
echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}"
|
||||||
|
echo "AVAILABLE_DOCS:"
|
||||||
|
check_file "$RESEARCH" "research.md"
|
||||||
|
check_file "$DATA_MODEL" "data-model.md"
|
||||||
|
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||||
|
check_file "$QUICKSTART" "quickstart.md"
|
||||||
|
fi
|
||||||
@@ -8,7 +8,7 @@ description: "Task list template for feature implementation"
|
|||||||
**Input**: Design documents from `/specs/[###-feature-name]/`
|
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||||
|
|
||||||
**Tests**: The examples below include test tasks. Per §5.1 of the constitution, TDD is non-negotiable — test tasks MUST appear before every implementation task. The test task labels below marked "OPTIONAL" refer to the *type* of test (E2E is best-effort per §5.2), not whether tests are written at all.
|
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
|
||||||
|
|
||||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ Examples of foundational tasks (adjust based on your project):
|
|||||||
|
|
||||||
**Independent Test**: [How to verify this story works on its own]
|
**Independent Test**: [How to verify this story works on its own]
|
||||||
|
|
||||||
### Tests for User Story 1 (REQUIRED per §5.1 — TDD) ⚠️
|
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
|
||||||
|
|
||||||
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ Examples of foundational tasks (adjust based on your project):
|
|||||||
|
|
||||||
**Independent Test**: [How to verify this story works on its own]
|
**Independent Test**: [How to verify this story works on its own]
|
||||||
|
|
||||||
### Tests for User Story 2 (REQUIRED per §5.1 — TDD) ⚠️
|
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
|
||||||
|
|
||||||
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
|
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||||
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
|
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
|
||||||
@@ -127,7 +127,7 @@ Examples of foundational tasks (adjust based on your project):
|
|||||||
|
|
||||||
**Independent Test**: [How to verify this story works on its own]
|
**Independent Test**: [How to verify this story works on its own]
|
||||||
|
|
||||||
### Tests for User Story 3 (REQUIRED per §5.1 — TDD) ⚠️
|
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
|
||||||
|
|
||||||
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
|
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||||
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
|
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
|
||||||
@@ -198,7 +198,7 @@ Examples of foundational tasks (adjust based on your project):
|
|||||||
## Parallel Example: User Story 1
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Launch all tests for User Story 1 together (TDD — write before implementation):
|
# Launch all tests for User Story 1 together (if tests requested):
|
||||||
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
|
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
|
||||||
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
|
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<!-- SPECKIT START -->
|
<!-- SPECKIT START -->
|
||||||
For additional context about technologies to be used, project structure,
|
For additional context about technologies to be used, project structure,
|
||||||
shell commands, and other important information, read the current plan at
|
shell commands, and other important information, read the current plan
|
||||||
`specs/016-copy-url-toast/plan.md`.
|
|
||||||
<!-- SPECKIT END -->
|
<!-- SPECKIT END -->
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ RUN groupadd --system --gid 1001 appgroup \
|
|||||||
&& useradd --system --uid 1001 --gid 1001 --no-create-home appuser
|
&& useradd --system --uid 1001 --gid 1001 --no-create-home appuser
|
||||||
|
|
||||||
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
|
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
|
||||||
|
# Explicitly list every source directory — add new top-level dirs here or they won't exist in prod
|
||||||
COPY --chown=appuser:appgroup app/ ./app/
|
COPY --chown=appuser:appgroup app/ ./app/
|
||||||
COPY --chown=appuser:appgroup alembic/ ./alembic/
|
COPY --chown=appuser:appgroup alembic/ ./alembic/
|
||||||
COPY --chown=appuser:appgroup alembic.ini .
|
COPY --chown=appuser:appgroup alembic.ini .
|
||||||
|
COPY --chown=appuser:appgroup scripts/ ./scripts/
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
|
|||||||
24
api/alembic/versions/003_add_short_id.py
Normal file
24
api/alembic/versions/003_add_short_id.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""add short_id column to images
|
||||||
|
|
||||||
|
Revision ID: 003
|
||||||
|
Revises: 002
|
||||||
|
Create Date: 2026-05-09
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "003"
|
||||||
|
down_revision = "002"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("images", sa.Column("short_id", sa.String(8), nullable=True))
|
||||||
|
op.create_index("ix_images_short_id", "images", ["short_id"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_images_short_id", table_name="images")
|
||||||
|
op.drop_column("images", "short_id")
|
||||||
24
api/alembic/versions/004_short_id_not_null.py
Normal file
24
api/alembic/versions/004_short_id_not_null.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""set short_id NOT NULL on images
|
||||||
|
|
||||||
|
Revision ID: 004
|
||||||
|
Revises: 003
|
||||||
|
Create Date: 2026-05-09
|
||||||
|
|
||||||
|
IMPORTANT: Run migrate_to_short_ids.py script BEFORE applying this migration.
|
||||||
|
This migration will fail if any rows still have short_id IS NULL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "004"
|
||||||
|
down_revision = "003"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.alter_column("images", "short_id", nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column("images", "short_id", nullable=True)
|
||||||
@@ -92,9 +92,7 @@ class LoginRateLimiter:
|
|||||||
rec.failures += 1
|
rec.failures += 1
|
||||||
if rec.failures >= self._max:
|
if rec.failures >= self._max:
|
||||||
rec.blocked_until = now + self._cooldown
|
rec.blocked_until = now + self._cooldown
|
||||||
logger.warning(
|
logger.warning("Login blocked for %s after %d failures", ip, rec.failures)
|
||||||
"Login blocked for %s after %d failures", ip, rec.failures
|
|
||||||
)
|
|
||||||
|
|
||||||
def record_success(self, ip: str) -> None:
|
def record_success(self, ip: str) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class Image(Base):
|
|||||||
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
width: Mapped[int] = mapped_column(Integer, nullable=False)
|
width: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
height: Mapped[int] = mapped_column(Integer, nullable=False)
|
height: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)
|
||||||
storage_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
storage_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
|
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ class ImageRepository:
|
|||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_by_short_id(self, short_id: str) -> Image | None:
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(Image)
|
||||||
|
.where(Image.short_id == short_id)
|
||||||
|
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def create(
|
async def create(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -37,6 +45,7 @@ class ImageRepository:
|
|||||||
width: int,
|
width: int,
|
||||||
height: int,
|
height: int,
|
||||||
storage_key: str,
|
storage_key: str,
|
||||||
|
short_id: str,
|
||||||
thumbnail_key: str | None = None,
|
thumbnail_key: str | None = None,
|
||||||
) -> Image:
|
) -> Image:
|
||||||
image = Image(
|
image = Image(
|
||||||
@@ -47,6 +56,7 @@ class ImageRepository:
|
|||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
storage_key=storage_key,
|
storage_key=storage_key,
|
||||||
|
short_id=short_id,
|
||||||
thumbnail_key=thumbnail_key,
|
thumbnail_key=thumbnail_key,
|
||||||
)
|
)
|
||||||
self._session.add(image)
|
self._session.add(image)
|
||||||
|
|||||||
@@ -48,9 +48,7 @@ class TagRepository:
|
|||||||
for name in tag_names:
|
for name in tag_names:
|
||||||
tag = await self.upsert_by_name(name)
|
tag = await self.upsert_by_name(name)
|
||||||
existing = await self._session.execute(
|
existing = await self._session.execute(
|
||||||
select(ImageTag).where(
|
select(ImageTag).where(ImageTag.image_id == image.id, ImageTag.tag_id == tag.id)
|
||||||
ImageTag.image_id == image.id, ImageTag.tag_id == tag.id
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none() is None:
|
if existing.scalar_one_or_none() is None:
|
||||||
self._session.add(ImageTag(image_id=image.id, tag_id=tag.id))
|
self._session.add(ImageTag(image_id=image.id, tag_id=tag.id))
|
||||||
@@ -88,7 +86,7 @@ class TagRepository:
|
|||||||
|
|
||||||
query = select(Tag, count_subq.label("image_count"))
|
query = select(Tag, count_subq.label("image_count"))
|
||||||
if prefix:
|
if prefix:
|
||||||
query = query.where(Tag.name.like(f"{prefix}%"))
|
query = query.where(Tag.name.ilike(f"%{prefix}%"))
|
||||||
if min_count > 0:
|
if min_count > 0:
|
||||||
query = query.where(count_subq >= min_count)
|
query = query.where(count_subq >= min_count)
|
||||||
|
|
||||||
@@ -102,7 +100,6 @@ class TagRepository:
|
|||||||
rows = await self._session.execute(paginated)
|
rows = await self._session.execute(paginated)
|
||||||
|
|
||||||
items = [
|
items = [
|
||||||
{"id": str(tag.id), "name": tag.name, "image_count": count}
|
{"id": str(tag.id), "name": tag.name, "image_count": count} for tag, count in rows.all()
|
||||||
for tag, count in rows.all()
|
|
||||||
]
|
]
|
||||||
return items, total
|
return items, total
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import struct
|
import struct
|
||||||
import uuid
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
|
||||||
@@ -15,7 +15,7 @@ from app.repositories.image_repo import ImageRepository
|
|||||||
from app.repositories.tag_repo import TagRepository
|
from app.repositories.tag_repo import TagRepository
|
||||||
from app.storage.backend import StorageBackend
|
from app.storage.backend import StorageBackend
|
||||||
from app.thumbnail import generate_thumbnail
|
from app.thumbnail import generate_thumbnail
|
||||||
from app.utils import compute_sha256
|
from app.utils import compute_sha256, generate_short_id
|
||||||
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
|
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -23,22 +23,35 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(tags=["images"])
|
router = APIRouter(tags=["images"])
|
||||||
|
|
||||||
|
|
||||||
|
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
|
||||||
|
|
||||||
|
|
||||||
def _error(detail: str, code: str, status: int):
|
def _error(detail: str, code: str, status: int):
|
||||||
raise HTTPException(status_code=status, detail={"detail": detail, "code": code})
|
raise HTTPException(status_code=status, detail={"detail": detail, "code": code})
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_short_id(short_id: str) -> str:
|
||||||
|
if not _SHORT_ID_RE.match(short_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={"detail": "Invalid image ID", "code": "invalid_short_id"},
|
||||||
|
)
|
||||||
|
return short_id
|
||||||
|
|
||||||
|
|
||||||
def _image_to_dict(
|
def _image_to_dict(
|
||||||
image: Image, *, cdn_base: str | None = None, duplicate: bool | None = None
|
image: Image, *, cdn_base: str | None = None, duplicate: bool | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
_base = cdn_base.strip().rstrip("/") if cdn_base else None
|
_base = cdn_base.strip().rstrip("/") if cdn_base else None
|
||||||
file_url = f"{_base}/{image.storage_key}" if _base else f"/api/v1/images/{image.id}/file"
|
file_url = f"{_base}/{image.storage_key}" if _base else f"/api/v1/i/{image.short_id}/file"
|
||||||
thumbnail_url = (
|
thumbnail_url = (
|
||||||
(f"{_base}/{image.thumbnail_key}" if _base else f"/api/v1/images/{image.id}/thumbnail")
|
(f"{_base}/{image.thumbnail_key}" if _base else f"/api/v1/i/{image.short_id}/thumbnail")
|
||||||
if image.thumbnail_key
|
if image.thumbnail_key
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
data: dict[str, Any] = {
|
data: dict[str, Any] = {
|
||||||
"id": str(image.id),
|
"id": str(image.id),
|
||||||
|
"short_id": image.short_id,
|
||||||
"hash": image.hash,
|
"hash": image.hash,
|
||||||
"filename": image.filename,
|
"filename": image.filename,
|
||||||
"mime_type": image.mime_type,
|
"mime_type": image.mime_type,
|
||||||
@@ -169,29 +182,49 @@ async def upload_image(
|
|||||||
)
|
)
|
||||||
|
|
||||||
width, height = _read_image_dimensions(data, mime_type)
|
width, height = _read_image_dimensions(data, mime_type)
|
||||||
await storage.put(hash_hex, data, mime_type)
|
|
||||||
|
|
||||||
thumbnail_key: str | None = None
|
from sqlalchemy.exc import IntegrityError
|
||||||
try:
|
|
||||||
thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)
|
for _ in range(10):
|
||||||
await storage.put(f"{hash_hex}-thumb", thumb_bytes, "image/webp")
|
short_id = generate_short_id()
|
||||||
thumbnail_key = f"{hash_hex}-thumb"
|
await storage.put(short_id, data, mime_type)
|
||||||
except Exception:
|
|
||||||
logger.warning(
|
thumbnail_key: str | None = None
|
||||||
"Thumbnail generation failed for %s; upload will proceed without thumbnail", hash_hex
|
try:
|
||||||
|
thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)
|
||||||
|
await storage.put(f"{short_id}-thumb", thumb_bytes, "image/webp")
|
||||||
|
thumbnail_key = f"{short_id}-thumb"
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Thumbnail generation failed for %s; proceeding without thumbnail", short_id
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
image = await image_repo.create(
|
||||||
|
hash_hex=hash_hex,
|
||||||
|
filename=file.filename or "upload",
|
||||||
|
mime_type=mime_type,
|
||||||
|
size_bytes=len(data),
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
storage_key=short_id,
|
||||||
|
short_id=short_id,
|
||||||
|
thumbnail_key=thumbnail_key,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
await storage.delete(short_id)
|
||||||
|
if thumbnail_key:
|
||||||
|
await storage.delete(thumbnail_key)
|
||||||
|
thumbnail_key = None
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"detail": "Failed to assign unique ID", "code": "id_collision"},
|
||||||
)
|
)
|
||||||
|
|
||||||
image = await image_repo.create(
|
|
||||||
hash_hex=hash_hex,
|
|
||||||
filename=file.filename or "upload",
|
|
||||||
mime_type=mime_type,
|
|
||||||
size_bytes=len(data),
|
|
||||||
width=width,
|
|
||||||
height=height,
|
|
||||||
storage_key=hash_hex,
|
|
||||||
thumbnail_key=thumbnail_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
if tag_names:
|
if tag_names:
|
||||||
tag_repo = TagRepository(db)
|
tag_repo = TagRepository(db)
|
||||||
await tag_repo.attach_tags(image, tag_names)
|
await tag_repo.attach_tags(image, tag_names)
|
||||||
@@ -221,15 +254,16 @@ async def list_images(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/images/{image_id}")
|
@router.get("/i/{short_id}")
|
||||||
async def get_image(
|
async def get_image(
|
||||||
image_id: uuid.UUID,
|
short_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
settings=Depends(get_settings),
|
settings=Depends(get_settings),
|
||||||
):
|
):
|
||||||
|
_validate_short_id(short_id)
|
||||||
_cdn_base = settings.s3_public_base_url
|
_cdn_base = settings.s3_public_base_url
|
||||||
image_repo = ImageRepository(db)
|
image_repo = ImageRepository(db)
|
||||||
image = await image_repo.get_by_id(image_id)
|
image = await image_repo.get_by_short_id(short_id)
|
||||||
if not image:
|
if not image:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
@@ -238,14 +272,15 @@ async def get_image(
|
|||||||
return _image_to_dict(image, cdn_base=_cdn_base)
|
return _image_to_dict(image, cdn_base=_cdn_base)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/images/{image_id}/file")
|
@router.get("/i/{short_id}/file")
|
||||||
async def serve_image_file(
|
async def serve_image_file(
|
||||||
image_id: uuid.UUID,
|
short_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
storage: StorageBackend = Depends(get_storage),
|
storage: StorageBackend = Depends(get_storage),
|
||||||
):
|
):
|
||||||
|
_validate_short_id(short_id)
|
||||||
image_repo = ImageRepository(db)
|
image_repo = ImageRepository(db)
|
||||||
image = await image_repo.get_by_id(image_id)
|
image = await image_repo.get_by_short_id(short_id)
|
||||||
if not image:
|
if not image:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
@@ -268,14 +303,15 @@ async def serve_image_file(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/images/{image_id}/thumbnail")
|
@router.get("/i/{short_id}/thumbnail")
|
||||||
async def serve_image_thumbnail(
|
async def serve_image_thumbnail(
|
||||||
image_id: uuid.UUID,
|
short_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
storage: StorageBackend = Depends(get_storage),
|
storage: StorageBackend = Depends(get_storage),
|
||||||
):
|
):
|
||||||
|
_validate_short_id(short_id)
|
||||||
image_repo = ImageRepository(db)
|
image_repo = ImageRepository(db)
|
||||||
image = await image_repo.get_by_id(image_id)
|
image = await image_repo.get_by_short_id(short_id)
|
||||||
if not image:
|
if not image:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
@@ -300,17 +336,18 @@ async def serve_image_thumbnail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/images/{image_id}/tags")
|
@router.patch("/i/{short_id}/tags")
|
||||||
async def update_image_tags(
|
async def update_image_tags(
|
||||||
image_id: uuid.UUID,
|
short_id: str,
|
||||||
body: dict,
|
body: dict,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: Identity = Depends(require_auth),
|
_: Identity = Depends(require_auth),
|
||||||
settings=Depends(get_settings),
|
settings=Depends(get_settings),
|
||||||
):
|
):
|
||||||
|
_validate_short_id(short_id)
|
||||||
_cdn_base = settings.s3_public_base_url
|
_cdn_base = settings.s3_public_base_url
|
||||||
image_repo = ImageRepository(db)
|
image_repo = ImageRepository(db)
|
||||||
image = await image_repo.get_by_id(image_id)
|
image = await image_repo.get_by_short_id(short_id)
|
||||||
if not image:
|
if not image:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
@@ -332,15 +369,16 @@ async def update_image_tags(
|
|||||||
return _image_to_dict(image, cdn_base=_cdn_base)
|
return _image_to_dict(image, cdn_base=_cdn_base)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/images/{image_id}", status_code=204)
|
@router.delete("/i/{short_id}", status_code=204)
|
||||||
async def delete_image(
|
async def delete_image(
|
||||||
image_id: uuid.UUID,
|
short_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
storage: StorageBackend = Depends(get_storage),
|
storage: StorageBackend = Depends(get_storage),
|
||||||
_: Identity = Depends(require_auth),
|
_: Identity = Depends(require_auth),
|
||||||
):
|
):
|
||||||
|
_validate_short_id(short_id)
|
||||||
image_repo = ImageRepository(db)
|
image_repo = ImageRepository(db)
|
||||||
image = await image_repo.get_by_id(image_id)
|
image = await image_repo.get_by_short_id(short_id)
|
||||||
if not image:
|
if not image:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
BASE62 = string.ascii_letters + string.digits
|
||||||
|
|
||||||
|
|
||||||
def compute_sha256(data: bytes) -> str:
|
def compute_sha256(data: bytes) -> str:
|
||||||
return hashlib.sha256(data).hexdigest()
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_short_id(length: int = 8) -> str:
|
||||||
|
return "".join(secrets.choice(BASE62) for _ in range(length))
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ dev = [
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py312"
|
target-version = "py312"
|
||||||
|
exclude = ["alembic/"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||||
|
|||||||
0
api/scripts/__init__.py
Normal file
0
api/scripts/__init__.py
Normal file
107
api/scripts/migrate_to_short_ids.py
Normal file
107
api/scripts/migrate_to_short_ids.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""
|
||||||
|
Migrate existing images to use short_id-based storage keys.
|
||||||
|
|
||||||
|
Run after applying Alembic migration 003 (adds short_id column).
|
||||||
|
Run before applying migration 004 (sets short_id NOT NULL).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m scripts.migrate_to_short_ids
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import get_session_factory
|
||||||
|
from app.models import Image
|
||||||
|
from app.storage.s3_backend import S3StorageBackend
|
||||||
|
from app.utils import generate_short_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_image(image: Any, storage: Any, session: Any) -> bool:
|
||||||
|
"""Migrate one image to a short_id-based key. Returns True if migrated, False if skipped."""
|
||||||
|
if image.short_id is not None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
new_short_id = generate_short_id()
|
||||||
|
old_key = image.storage_key
|
||||||
|
old_thumb_key = image.thumbnail_key
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await storage.get(old_key)
|
||||||
|
await storage.put(new_short_id, data, image.mime_type)
|
||||||
|
# Verify copy succeeded
|
||||||
|
await storage.get(new_short_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to copy storage object for image %s: %s", image.id, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
new_thumb_key: str | None = None
|
||||||
|
if old_thumb_key:
|
||||||
|
try:
|
||||||
|
thumb_data = await storage.get(old_thumb_key)
|
||||||
|
new_thumb_key = f"{new_short_id}-thumb"
|
||||||
|
await storage.put(new_thumb_key, thumb_data, "image/webp")
|
||||||
|
await storage.get(new_thumb_key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to copy thumbnail for image %s: %s", image.id, exc)
|
||||||
|
new_thumb_key = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
image.short_id = new_short_id
|
||||||
|
image.storage_key = new_short_id
|
||||||
|
image.thumbnail_key = new_thumb_key
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
await storage.delete(old_key)
|
||||||
|
if old_thumb_key and new_thumb_key:
|
||||||
|
await storage.delete(old_thumb_key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to update DB record for image %s: %s", image.id, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migration(images: list, storage: Any, session: Any) -> tuple[int, int, int]:
|
||||||
|
"""Process a list of images. Returns (migrated, skipped, failed) counts."""
|
||||||
|
migrated = skipped = failed = 0
|
||||||
|
for image in images:
|
||||||
|
if image.short_id is not None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
success = await migrate_image(image, storage, session)
|
||||||
|
if success:
|
||||||
|
migrated += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Unexpected error migrating image %s: %s", image.id, exc)
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return migrated, skipped, failed
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
storage = S3StorageBackend()
|
||||||
|
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
result = await session.execute(select(Image).where(Image.short_id.is_(None)))
|
||||||
|
images = list(result.scalars().all())
|
||||||
|
logger.info("Found %d images to migrate", len(images))
|
||||||
|
|
||||||
|
migrated, skipped, failed = await run_migration(images, storage, session)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
print(f"Migrated: {migrated}, Skipped: {skipped}, Failed: {failed}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
T065 — DELETE /api/v1/images/{id} → 204; subsequent GET returns 404
|
T065 — DELETE /api/v1/i/{short_id} → 204; subsequent GET returns 404
|
||||||
T066 — DELETE verifies MinIO object is removed
|
T066 — DELETE verifies MinIO object is removed
|
||||||
T067 — DELETE of unknown ID → 404 image_not_found
|
T067 — DELETE of unknown ID → 404 image_not_found
|
||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
import uuid
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
@@ -28,12 +27,12 @@ async def test_delete_removes_record(authed_client):
|
|||||||
files={"file": ("del-test.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("del-test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["short_id"]
|
||||||
|
|
||||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
|
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
|
||||||
assert delete_resp.status_code == 204
|
assert delete_resp.status_code == 204
|
||||||
|
|
||||||
get_resp = await client.get(f"/api/v1/images/{image_id}")
|
get_resp = await client.get(f"/api/v1/i/{image_id}")
|
||||||
assert get_resp.status_code == 404
|
assert get_resp.status_code == 404
|
||||||
assert get_resp.json()["code"] == "image_not_found"
|
assert get_resp.json()["code"] == "image_not_found"
|
||||||
|
|
||||||
@@ -49,13 +48,13 @@ async def test_delete_removes_storage_object(authed_client):
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert upload.status_code in (200, 201)
|
assert upload.status_code in (200, 201)
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["short_id"]
|
||||||
|
|
||||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
|
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
|
||||||
assert delete_resp.status_code == 204
|
assert delete_resp.status_code == 204
|
||||||
|
|
||||||
# Confirm storage redirect no longer works (404 since record is gone)
|
# Confirm storage redirect no longer works (404 since record is gone)
|
||||||
file_resp = await client.get(f"/api/v1/images/{image_id}/file")
|
file_resp = await client.get(f"/api/v1/i/{image_id}/file")
|
||||||
assert file_resp.status_code == 404
|
assert file_resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ async def test_delete_removes_storage_object(authed_client):
|
|||||||
async def test_delete_unknown_id_returns_404(authed_client):
|
async def test_delete_unknown_id_returns_404(authed_client):
|
||||||
client, token = authed_client
|
client, token = authed_client
|
||||||
response = await client.delete(
|
response = await client.delete(
|
||||||
f"/api/v1/images/{uuid.uuid4()}",
|
"/api/v1/i/NotFound",
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
@@ -85,12 +84,12 @@ async def test_delete_removes_thumbnail(authed_client):
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert upload.status_code == 201
|
assert upload.status_code == 201
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["short_id"]
|
||||||
assert upload.json()["thumbnail_key"] is not None
|
assert upload.json()["thumbnail_key"] is not None
|
||||||
|
|
||||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
|
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
|
||||||
assert delete_resp.status_code == 204
|
assert delete_resp.status_code == 204
|
||||||
|
|
||||||
thumb_resp = await client.get(f"/api/v1/images/{image_id}/thumbnail")
|
thumb_resp = await client.get(f"/api/v1/i/{image_id}/thumbnail")
|
||||||
assert thumb_resp.status_code == 404
|
assert thumb_resp.status_code == 404
|
||||||
assert thumb_resp.json()["code"] == "image_not_found"
|
assert thumb_resp.json()["code"] == "image_not_found"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ Tests that write endpoints require authentication (US2).
|
|||||||
These use the authed_client fixture which wires JWTAuthProvider.
|
These use the authed_client fixture which wires JWTAuthProvider.
|
||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
import uuid
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -42,8 +41,7 @@ async def test_upload_with_valid_token_succeeds(authed_client):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_without_token_returns_401(authed_client):
|
async def test_delete_without_token_returns_401(authed_client):
|
||||||
client, _ = authed_client
|
client, _ = authed_client
|
||||||
fake_id = uuid.uuid4()
|
response = await client.delete("/api/v1/i/NotFound")
|
||||||
response = await client.delete(f"/api/v1/images/{fake_id}")
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert response.json().get("code") == "unauthorized"
|
assert response.json().get("code") == "unauthorized"
|
||||||
|
|
||||||
@@ -57,9 +55,9 @@ async def test_delete_with_valid_token_succeeds(authed_client):
|
|||||||
files={"file": ("del-protected.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("del-protected.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["short_id"]
|
||||||
response = await client.delete(
|
response = await client.delete(
|
||||||
f"/api/v1/images/{image_id}",
|
f"/api/v1/i/{image_id}",
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
@@ -68,9 +66,8 @@ async def test_delete_with_valid_token_succeeds(authed_client):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_patch_tags_without_token_returns_401(authed_client):
|
async def test_patch_tags_without_token_returns_401(authed_client):
|
||||||
client, _ = authed_client
|
client, _ = authed_client
|
||||||
fake_id = uuid.uuid4()
|
|
||||||
response = await client.patch(
|
response = await client.patch(
|
||||||
f"/api/v1/images/{fake_id}/tags",
|
"/api/v1/i/NotFound/tags",
|
||||||
json={"tags": ["a"]},
|
json={"tags": ["a"]},
|
||||||
)
|
)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
@@ -86,9 +83,9 @@ async def test_patch_tags_with_valid_token_succeeds(authed_client):
|
|||||||
files={"file": ("tag-protected.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("tag-protected.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["short_id"]
|
||||||
response = await client.patch(
|
response = await client.patch(
|
||||||
f"/api/v1/images/{image_id}/tags",
|
f"/api/v1/i/{image_id}/tags",
|
||||||
json={"tags": ["protected-tag"]},
|
json={"tags": ["protected-tag"]},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ async def test_get_image_without_token_is_200(authed_client):
|
|||||||
files={"file": ("pub-test.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("pub-test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["short_id"]
|
||||||
response = await client.get(f"/api/v1/images/{image_id}")
|
response = await client.get(f"/api/v1/i/{image_id}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
@@ -44,8 +44,8 @@ async def test_serve_file_without_token_is_200(authed_client):
|
|||||||
files={"file": ("pub-file.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("pub-file.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["short_id"]
|
||||||
response = await client.get(f"/api/v1/images/{image_id}/file")
|
response = await client.get(f"/api/v1/i/{image_id}/file")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
@@ -58,8 +58,8 @@ async def test_serve_thumbnail_without_token_is_200(authed_client):
|
|||||||
files={"file": ("pub-thumb.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("pub-thumb.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["short_id"]
|
||||||
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
|
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
T055 — GET /api/v1/images/{id}/file → 200 with binary content, ETag, Cache-Control
|
T055 — GET /api/v1/i/{short_id}/file → 200 with binary content, ETag, Cache-Control
|
||||||
T056 — /file for unknown ID → 404 image_not_found
|
T056 — /file for unknown ID → 404 image_not_found
|
||||||
T057 — /file response exposes no storage-specific details
|
T057 — /file response exposes no storage-specific details
|
||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
import uuid
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
@@ -39,10 +38,10 @@ async def test_file_returns_200_with_content(authed_client):
|
|||||||
)
|
)
|
||||||
assert upload.status_code in (200, 201)
|
assert upload.status_code in (200, 201)
|
||||||
upload_body = upload.json()
|
upload_body = upload.json()
|
||||||
image_id = upload_body["id"]
|
image_id = upload_body["short_id"]
|
||||||
image_hash = upload_body["hash"]
|
image_hash = upload_body["hash"]
|
||||||
|
|
||||||
response = await client.get(f"/api/v1/images/{image_id}/file")
|
response = await client.get(f"/api/v1/i/{image_id}/file")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["content-type"].startswith("image/")
|
assert response.headers["content-type"].startswith("image/")
|
||||||
assert response.headers["etag"] == f'"{image_hash}"'
|
assert response.headers["etag"] == f'"{image_hash}"'
|
||||||
@@ -52,7 +51,7 @@ async def test_file_returns_200_with_content(authed_client):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_file_unknown_id_returns_404(client):
|
async def test_file_unknown_id_returns_404(client):
|
||||||
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/file")
|
response = await client.get("/api/v1/i/NotFound/file")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["code"] == "image_not_found"
|
assert body["code"] == "image_not_found"
|
||||||
@@ -68,9 +67,9 @@ async def test_file_response_exposes_no_storage_details(authed_client):
|
|||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert upload.status_code in (200, 201)
|
assert upload.status_code in (200, 201)
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["short_id"]
|
||||||
|
|
||||||
response = await client.get(f"/api/v1/images/{image_id}/file")
|
response = await client.get(f"/api/v1/i/{image_id}/file")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "location" not in response.headers
|
assert "location" not in response.headers
|
||||||
assert "minio" not in response.text.lower()
|
assert "minio" not in response.text.lower()
|
||||||
@@ -89,10 +88,10 @@ async def test_thumbnail_returns_webp(authed_client):
|
|||||||
)
|
)
|
||||||
assert upload.status_code == 201
|
assert upload.status_code == 201
|
||||||
body = upload.json()
|
body = upload.json()
|
||||||
image_id = body["id"]
|
image_id = body["short_id"]
|
||||||
image_hash = body["hash"]
|
image_hash = body["hash"]
|
||||||
|
|
||||||
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
|
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["content-type"] == "image/webp"
|
assert response.headers["content-type"] == "image/webp"
|
||||||
assert response.headers["etag"] == f'"{image_hash}"'
|
assert response.headers["etag"] == f'"{image_hash}"'
|
||||||
@@ -110,15 +109,15 @@ async def test_thumbnail_fallback_returns_original(authed_client, db_session):
|
|||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert upload.status_code == 201
|
assert upload.status_code == 201
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["short_id"]
|
||||||
|
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
update(Image).where(Image.id == uuid.UUID(image_id)).values(thumbnail_key=None)
|
update(Image).where(Image.short_id == image_id).values(thumbnail_key=None)
|
||||||
)
|
)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
db_session.expire_all()
|
db_session.expire_all()
|
||||||
|
|
||||||
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
|
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "image/jpeg" in response.headers["content-type"]
|
assert "image/jpeg" in response.headers["content-type"]
|
||||||
assert len(response.content) > 0
|
assert len(response.content) > 0
|
||||||
@@ -126,7 +125,7 @@ async def test_thumbnail_fallback_returns_original(authed_client, db_session):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_thumbnail_unknown_id_returns_404(client):
|
async def test_thumbnail_unknown_id_returns_404(client):
|
||||||
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/thumbnail")
|
response = await client.get("/api/v1/i/NotFound/thumbnail")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["code"] == "image_not_found"
|
assert body["code"] == "image_not_found"
|
||||||
|
|||||||
@@ -81,10 +81,10 @@ async def test_patch_replaces_tag_set(authed_client):
|
|||||||
data={"tags": "old-tag"},
|
data={"tags": "old-tag"},
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
image_id = r1.json()["id"]
|
image_id = r1.json()["short_id"]
|
||||||
|
|
||||||
patch = await client.patch(
|
patch = await client.patch(
|
||||||
f"/api/v1/images/{image_id}/tags",
|
f"/api/v1/i/{image_id}/tags",
|
||||||
json={"tags": ["new-tag", "another"]},
|
json={"tags": ["new-tag", "another"]},
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
@@ -104,10 +104,10 @@ async def test_patch_invalid_tag_returns_422(authed_client):
|
|||||||
files={"file": ("invalid-tag-test.png", io.BytesIO(data), "image/png")},
|
files={"file": ("invalid-tag-test.png", io.BytesIO(data), "image/png")},
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
image_id = r1.json()["id"]
|
image_id = r1.json()["short_id"]
|
||||||
|
|
||||||
patch = await client.patch(
|
patch = await client.patch(
|
||||||
f"/api/v1/images/{image_id}/tags",
|
f"/api/v1/i/{image_id}/tags",
|
||||||
json={"tags": ["valid", "INVALID TAG WITH SPACES!"]},
|
json={"tags": ["valid", "INVALID TAG WITH SPACES!"]},
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ T026 — valid JPEG upload → 201, record in DB, object in MinIO
|
|||||||
T027 — same image uploaded twice → 200, duplicate: true, no second MinIO object
|
T027 — same image uploaded twice → 200, duplicate: true, no second MinIO object
|
||||||
T028 — invalid MIME type → 422 invalid_mime_type (error envelope with code field)
|
T028 — invalid MIME type → 422 invalid_mime_type (error envelope with code field)
|
||||||
T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large
|
T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large
|
||||||
T079 — GET /api/v1/images/{id} 404 → error envelope shape
|
T013 — upload produces short_id; storage_key equals short_id; thumbnail_key = {short_id}-thumb
|
||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
import uuid
|
import re
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -111,13 +111,81 @@ async def test_upload_oversized_file_returns_422(authed_client):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_unknown_image_returns_404_with_envelope(client):
|
async def test_get_unknown_image_returns_404_with_envelope(client):
|
||||||
response = await client.get(f"/api/v1/images/{uuid.uuid4()}")
|
response = await client.get("/api/v1/i/NotFound")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["code"] == "image_not_found"
|
assert body["code"] == "image_not_found"
|
||||||
assert "detail" in body
|
assert "detail" in body
|
||||||
|
|
||||||
|
|
||||||
|
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_returns_short_id(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
data = _minimal_jpeg()
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/images",
|
||||||
|
files={"file": ("s1.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
body = response.json()
|
||||||
|
assert "short_id" in body
|
||||||
|
assert _SHORT_ID_RE.match(body["short_id"]), f"short_id invalid: {body['short_id']}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_storage_key_equals_short_id(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
data = _real_jpeg(color=(10, 20, 30))
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/images",
|
||||||
|
files={"file": ("s2.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
body = response.json()
|
||||||
|
assert body["storage_key"] == body["short_id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_thumbnail_key_equals_short_id_thumb(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
data = _real_jpeg(color=(30, 60, 90))
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/images",
|
||||||
|
files={"file": ("s3.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
body = response.json()
|
||||||
|
if body["thumbnail_key"] is not None:
|
||||||
|
assert body["thumbnail_key"] == f"{body['short_id']}-thumb"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_duplicate_upload_returns_same_short_id(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
data = _real_jpeg(color=(200, 100, 50))
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
r1 = await client.post(
|
||||||
|
"/api/v1/images",
|
||||||
|
files={"file": ("dup_short.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r1.status_code in (200, 201)
|
||||||
|
r2 = await client.post(
|
||||||
|
"/api/v1/images",
|
||||||
|
files={"file": ("dup_short.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["duplicate"] is True
|
||||||
|
assert r2.json()["short_id"] == r1.json()["short_id"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upload_returns_thumbnail_key(authed_client):
|
async def test_upload_returns_thumbnail_key(authed_client):
|
||||||
client, token = authed_client
|
client, token = authed_client
|
||||||
@@ -133,9 +201,9 @@ async def test_upload_returns_thumbnail_key(authed_client):
|
|||||||
assert body["thumbnail_key"] is not None
|
assert body["thumbnail_key"] is not None
|
||||||
assert body["thumbnail_key"].endswith("-thumb")
|
assert body["thumbnail_key"].endswith("-thumb")
|
||||||
assert "file_url" in body
|
assert "file_url" in body
|
||||||
assert body["file_url"].startswith("/api/v1/images/")
|
assert body["file_url"].startswith("/api/v1/i/")
|
||||||
assert "thumbnail_url" in body
|
assert "thumbnail_url" in body
|
||||||
assert body["thumbnail_url"].startswith("/api/v1/images/")
|
assert body["thumbnail_url"].startswith("/api/v1/i/")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -177,5 +245,5 @@ async def test_upload_succeeds_when_thumbnail_fails(authed_client):
|
|||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["thumbnail_key"] is None
|
assert body["thumbnail_key"] is None
|
||||||
assert "file_url" in body
|
assert "file_url" in body
|
||||||
assert body["file_url"].startswith("/api/v1/images/")
|
assert body["file_url"].startswith("/api/v1/i/")
|
||||||
assert body["thumbnail_url"] is None
|
assert body["thumbnail_url"] is None
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
_BASE_ENV = {
|
_BASE_ENV = {
|
||||||
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
|
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
|
||||||
"S3_ENDPOINT_URL": "http://localhost:9000",
|
"S3_ENDPOINT_URL": "http://localhost:9000",
|
||||||
@@ -26,6 +24,7 @@ def test_settings_load_from_env(monkeypatch):
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import app.config as config_module
|
import app.config as config_module
|
||||||
|
|
||||||
importlib.reload(config_module)
|
importlib.reload(config_module)
|
||||||
|
|
||||||
s = config_module.Settings()
|
s = config_module.Settings()
|
||||||
@@ -43,6 +42,7 @@ def test_settings_max_upload_bytes_override(monkeypatch):
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import app.config as config_module
|
import app.config as config_module
|
||||||
|
|
||||||
importlib.reload(config_module)
|
importlib.reload(config_module)
|
||||||
|
|
||||||
s = config_module.Settings()
|
s = config_module.Settings()
|
||||||
@@ -55,6 +55,7 @@ def test_settings_jwt_expiry_override(monkeypatch):
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import app.config as config_module
|
import app.config as config_module
|
||||||
|
|
||||||
importlib.reload(config_module)
|
importlib.reload(config_module)
|
||||||
|
|
||||||
s = config_module.Settings()
|
s = config_module.Settings()
|
||||||
@@ -67,6 +68,7 @@ def test_api_docs_enabled_default(monkeypatch):
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import app.config as config_module
|
import app.config as config_module
|
||||||
|
|
||||||
importlib.reload(config_module)
|
importlib.reload(config_module)
|
||||||
|
|
||||||
s = config_module.Settings()
|
s = config_module.Settings()
|
||||||
@@ -79,6 +81,7 @@ def test_api_docs_enabled_false(monkeypatch):
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import app.config as config_module
|
import app.config as config_module
|
||||||
|
|
||||||
importlib.reload(config_module)
|
importlib.reload(config_module)
|
||||||
|
|
||||||
s = config_module.Settings()
|
s = config_module.Settings()
|
||||||
@@ -91,6 +94,7 @@ def test_api_docs_invalid_value_defaults_to_enabled(monkeypatch):
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import app.config as config_module
|
import app.config as config_module
|
||||||
|
|
||||||
importlib.reload(config_module)
|
importlib.reload(config_module)
|
||||||
|
|
||||||
s = config_module.Settings()
|
s = config_module.Settings()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from app.utils import compute_sha256
|
from app.utils import compute_sha256, generate_short_id
|
||||||
|
|
||||||
|
|
||||||
def test_sha256_known_bytes():
|
def test_sha256_known_bytes():
|
||||||
@@ -19,3 +19,24 @@ def test_sha256_returns_64_char_hex():
|
|||||||
result = compute_sha256(b"test data")
|
result = compute_sha256(b"test data")
|
||||||
assert len(result) == 64
|
assert len(result) == 64
|
||||||
assert all(c in "0123456789abcdef" for c in result)
|
assert all(c in "0123456789abcdef" for c in result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_short_id_length():
|
||||||
|
assert len(generate_short_id()) == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_short_id_charset():
|
||||||
|
result = generate_short_id()
|
||||||
|
assert all(
|
||||||
|
c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" for c in result
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_short_id_randomness():
|
||||||
|
assert generate_short_id() != generate_short_id()
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_short_id_importable():
|
||||||
|
from app.utils import generate_short_id as fn
|
||||||
|
|
||||||
|
assert callable(fn)
|
||||||
|
|||||||
110
api/tests/unit/test_migration.py
Normal file
110
api/tests/unit/test_migration.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Unit tests for migrate_to_short_ids script logic."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_image_null_short_id():
|
||||||
|
img = MagicMock()
|
||||||
|
img.id = "img-uuid-1"
|
||||||
|
img.short_id = None
|
||||||
|
img.storage_key = "oldhashkey1234567890"
|
||||||
|
img.thumbnail_key = "oldhashkey1234567890-thumb"
|
||||||
|
img.mime_type = "image/jpeg"
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_image_with_short_id():
|
||||||
|
img = MagicMock()
|
||||||
|
img.id = "img-uuid-2"
|
||||||
|
img.short_id = "AbCd1234"
|
||||||
|
img.storage_key = "AbCd1234"
|
||||||
|
img.thumbnail_key = "AbCd1234-thumb"
|
||||||
|
img.mime_type = "image/jpeg"
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migrate_processes_image_without_short_id(mock_image_null_short_id):
|
||||||
|
"""Images with short_id IS NULL are processed: storage copied, DB updated, old keys deleted."""
|
||||||
|
from scripts.migrate_to_short_ids import migrate_image
|
||||||
|
|
||||||
|
storage = MagicMock()
|
||||||
|
storage.get = AsyncMock(return_value=b"imagedata")
|
||||||
|
storage.put = AsyncMock()
|
||||||
|
storage.delete = AsyncMock()
|
||||||
|
|
||||||
|
session = MagicMock()
|
||||||
|
session.execute = AsyncMock()
|
||||||
|
session.flush = AsyncMock()
|
||||||
|
|
||||||
|
old_key = mock_image_null_short_id.storage_key
|
||||||
|
new_short_id = "NewSh123"
|
||||||
|
with patch("scripts.migrate_to_short_ids.generate_short_id", return_value=new_short_id):
|
||||||
|
result = await migrate_image(mock_image_null_short_id, storage, session)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
storage.put.assert_any_call(new_short_id, b"imagedata", "image/jpeg")
|
||||||
|
storage.delete.assert_any_call(old_key)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migrate_skips_image_with_short_id(mock_image_with_short_id):
|
||||||
|
"""Images that already have a short_id are skipped."""
|
||||||
|
from scripts.migrate_to_short_ids import migrate_image
|
||||||
|
|
||||||
|
storage = MagicMock()
|
||||||
|
session = MagicMock()
|
||||||
|
|
||||||
|
result = await migrate_image(mock_image_with_short_id, storage, session)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
storage.get.assert_not_called() if hasattr(storage.get, "assert_not_called") else None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migrate_continues_on_storage_error(mock_image_null_short_id):
|
||||||
|
"""If storage copy fails, error is logged and migrate_image returns False without aborting."""
|
||||||
|
from scripts.migrate_to_short_ids import migrate_image
|
||||||
|
|
||||||
|
storage = MagicMock()
|
||||||
|
storage.get = AsyncMock(side_effect=Exception("storage read error"))
|
||||||
|
storage.put = AsyncMock()
|
||||||
|
storage.delete = AsyncMock()
|
||||||
|
|
||||||
|
session = MagicMock()
|
||||||
|
session.execute = AsyncMock()
|
||||||
|
session.flush = AsyncMock()
|
||||||
|
|
||||||
|
with patch("scripts.migrate_to_short_ids.generate_short_id", return_value="ErrSh123"):
|
||||||
|
result = await migrate_image(mock_image_null_short_id, storage, session)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
storage.put.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migrate_summary_counts(mock_image_null_short_id, mock_image_with_short_id):
|
||||||
|
"""run_migration reports correct migrated and skipped counts."""
|
||||||
|
from scripts.migrate_to_short_ids import run_migration
|
||||||
|
|
||||||
|
storage = MagicMock()
|
||||||
|
storage.get = AsyncMock(return_value=b"data")
|
||||||
|
storage.put = AsyncMock()
|
||||||
|
storage.delete = AsyncMock()
|
||||||
|
|
||||||
|
session = MagicMock()
|
||||||
|
session.execute = AsyncMock()
|
||||||
|
session.flush = AsyncMock()
|
||||||
|
|
||||||
|
images = [mock_image_null_short_id, mock_image_with_short_id]
|
||||||
|
|
||||||
|
with patch("scripts.migrate_to_short_ids.generate_short_id", return_value="NewSh999"):
|
||||||
|
migrated, skipped, failed = await run_migration(images, storage, session)
|
||||||
|
|
||||||
|
assert migrated == 1
|
||||||
|
assert skipped == 1
|
||||||
|
assert failed == 0
|
||||||
59
api/tests/unit/test_short_id.py
Normal file
59
api/tests/unit/test_short_id.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Unit tests for short_id generation, validation, and repository lookup."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.routers.images import _validate_short_id
|
||||||
|
from app.utils import generate_short_id
|
||||||
|
|
||||||
|
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_short_id_accepts_valid():
|
||||||
|
_validate_short_id("AbCd1234") # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_short_id_rejects_too_long():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_validate_short_id("toolong!!")
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_short_id_rejects_too_short():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_validate_short_id("short")
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_short_id_rejects_invalid_chars():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_validate_short_id("has spa!")
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_short_id_unique():
|
||||||
|
ids = {generate_short_id() for _ in range(100)}
|
||||||
|
assert len(ids) > 90 # collision in 100 draws would be astronomically unlikely
|
||||||
|
|
||||||
|
|
||||||
|
def test_repo_get_by_short_id_uses_correct_field():
|
||||||
|
"""get_by_short_id selects on Image.short_id, not Image.id."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.repositories.image_repo import ImageRepository
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
scalar = MagicMock()
|
||||||
|
scalar.scalar_one_or_none = MagicMock(return_value=None)
|
||||||
|
mock_session.execute = AsyncMock(return_value=scalar)
|
||||||
|
|
||||||
|
repo = ImageRepository(mock_session)
|
||||||
|
asyncio.get_event_loop().run_until_complete(repo.get_by_short_id("AbCd1234"))
|
||||||
|
|
||||||
|
call_args = mock_session.execute.call_args[0][0]
|
||||||
|
compiled = call_args.compile(compile_kwargs={"literal_binds": True})
|
||||||
|
assert "short_id" in str(compiled)
|
||||||
|
assert "AbCd1234" in str(compiled)
|
||||||
@@ -2,17 +2,21 @@
|
|||||||
T037 — tag normalisation: uppercase → lowercase, whitespace stripped
|
T037 — tag normalisation: uppercase → lowercase, whitespace stripped
|
||||||
T038 — tag validation: rejects names > 64 chars, invalid chars
|
T038 — tag validation: rejects names > 64 chars, invalid chars
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.repositories.tag_repo import TagRepository
|
from app.repositories.tag_repo import TagRepository
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("raw,expected", [
|
@pytest.mark.parametrize(
|
||||||
("Cat", "cat"),
|
"raw,expected",
|
||||||
(" funny ", "funny"),
|
[
|
||||||
("REACTION", "reaction"),
|
("Cat", "cat"),
|
||||||
(" MiXeD ", "mixed"),
|
(" funny ", "funny"),
|
||||||
])
|
("REACTION", "reaction"),
|
||||||
|
(" MiXeD ", "mixed"),
|
||||||
|
],
|
||||||
|
)
|
||||||
def test_normalise_lowercases_and_strips(raw, expected):
|
def test_normalise_lowercases_and_strips(raw, expected):
|
||||||
assert TagRepository.normalise(raw) == expected
|
assert TagRepository.normalise(raw) == expected
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Unit tests for thumbnail generation utility."""
|
"""Unit tests for thumbnail generation utility."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.routers.images import _image_to_dict
|
from app.routers.images import _image_to_dict
|
||||||
|
|
||||||
|
|
||||||
def _make_image(*, thumbnail_key=None):
|
def _make_image(*, thumbnail_key=None):
|
||||||
img = MagicMock()
|
img = MagicMock()
|
||||||
img.id = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
img.id = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||||
|
img.short_id = "AbCd1234"
|
||||||
img.hash = "abc123"
|
img.hash = "abc123"
|
||||||
img.filename = "test.jpg"
|
img.filename = "test.jpg"
|
||||||
img.mime_type = "image/jpeg"
|
img.mime_type = "image/jpeg"
|
||||||
@@ -27,6 +26,7 @@ def test_cdn_configured_with_thumbnail():
|
|||||||
result = _image_to_dict(img, cdn_base="https://cdn.example.com")
|
result = _image_to_dict(img, cdn_base="https://cdn.example.com")
|
||||||
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
|
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
|
||||||
assert result["thumbnail_url"] == "https://cdn.example.com/abc123storagekey-thumb"
|
assert result["thumbnail_url"] == "https://cdn.example.com/abc123storagekey-thumb"
|
||||||
|
assert result["short_id"] == "AbCd1234"
|
||||||
|
|
||||||
|
|
||||||
def test_cdn_configured_no_thumbnail():
|
def test_cdn_configured_no_thumbnail():
|
||||||
@@ -34,19 +34,20 @@ def test_cdn_configured_no_thumbnail():
|
|||||||
result = _image_to_dict(img, cdn_base="https://cdn.example.com")
|
result = _image_to_dict(img, cdn_base="https://cdn.example.com")
|
||||||
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
|
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
|
||||||
assert result["thumbnail_url"] is None
|
assert result["thumbnail_url"] is None
|
||||||
|
assert result["short_id"] == "AbCd1234"
|
||||||
|
|
||||||
|
|
||||||
def test_no_cdn_with_thumbnail():
|
def test_no_cdn_with_thumbnail():
|
||||||
img = _make_image(thumbnail_key="abc123storagekey-thumb")
|
img = _make_image(thumbnail_key="abc123storagekey-thumb")
|
||||||
result = _image_to_dict(img, cdn_base=None)
|
result = _image_to_dict(img, cdn_base=None)
|
||||||
assert result["file_url"] == "/api/v1/images/00000000-0000-0000-0000-000000000001/file"
|
assert result["file_url"] == "/api/v1/i/AbCd1234/file"
|
||||||
assert result["thumbnail_url"] == "/api/v1/images/00000000-0000-0000-0000-000000000001/thumbnail"
|
assert result["thumbnail_url"] == "/api/v1/i/AbCd1234/thumbnail"
|
||||||
|
|
||||||
|
|
||||||
def test_no_cdn_no_thumbnail():
|
def test_no_cdn_no_thumbnail():
|
||||||
img = _make_image(thumbnail_key=None)
|
img = _make_image(thumbnail_key=None)
|
||||||
result = _image_to_dict(img, cdn_base=None)
|
result = _image_to_dict(img, cdn_base=None)
|
||||||
assert result["file_url"] == "/api/v1/images/00000000-0000-0000-0000-000000000001/file"
|
assert result["file_url"] == "/api/v1/i/AbCd1234/file"
|
||||||
assert result["thumbnail_url"] is None
|
assert result["thumbnail_url"] is None
|
||||||
|
|
||||||
|
|
||||||
@@ -63,3 +64,9 @@ def test_cdn_trailing_whitespace_normalised():
|
|||||||
result = _image_to_dict(img, cdn_base="https://cdn.example.com ")
|
result = _image_to_dict(img, cdn_base="https://cdn.example.com ")
|
||||||
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
|
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
|
||||||
assert result["thumbnail_url"] == "https://cdn.example.com/abc123storagekey-thumb"
|
assert result["thumbnail_url"] == "https://cdn.example.com/abc123storagekey-thumb"
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_id_in_response():
|
||||||
|
img = _make_image()
|
||||||
|
result = _image_to_dict(img, cdn_base=None)
|
||||||
|
assert result["short_id"] == "AbCd1234"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: migrate
|
- name: migrate
|
||||||
image: git.juggalol.com/juggalol/reactbin-api:v1.3.0
|
image: git.juggalol.com/juggalol/reactbin-api:v1.4.3
|
||||||
command: ["alembic", "upgrade", "head"]
|
command: ["alembic", "upgrade", "head"]
|
||||||
workingDir: /app
|
workingDir: /app
|
||||||
envFrom:
|
envFrom:
|
||||||
@@ -26,7 +26,7 @@ spec:
|
|||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: api
|
- name: api
|
||||||
image: git.juggalol.com/juggalol/reactbin-api:v1.3.0
|
image: git.juggalol.com/juggalol/reactbin-api:v1.4.3
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
envFrom:
|
envFrom:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: ui
|
- name: ui
|
||||||
image: git.juggalol.com/juggalol/reactbin-ui:v1.3.0
|
image: git.juggalol.com/juggalol/reactbin-ui:v1.4.3
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
|
|||||||
0
specs/001-reaction-image-board/SHIPPED
Normal file
0
specs/001-reaction-image-board/SHIPPED
Normal file
0
specs/002-api-image-proxy/SHIPPED
Normal file
0
specs/002-api-image-proxy/SHIPPED
Normal file
0
specs/003-upload-thumbnails/SHIPPED
Normal file
0
specs/003-upload-thumbnails/SHIPPED
Normal file
0
specs/004-jwt-bearer-auth/SHIPPED
Normal file
0
specs/004-jwt-bearer-auth/SHIPPED
Normal file
0
specs/005-ui-polish/SHIPPED
Normal file
0
specs/005-ui-polish/SHIPPED
Normal file
0
specs/006-header-nav-signout/SHIPPED
Normal file
0
specs/006-header-nav-signout/SHIPPED
Normal file
0
specs/007-tag-browser/SHIPPED
Normal file
0
specs/007-tag-browser/SHIPPED
Normal file
0
specs/008-postgres-integration-tests/SHIPPED
Normal file
0
specs/008-postgres-integration-tests/SHIPPED
Normal file
0
specs/009-login-rate-limiting/SHIPPED
Normal file
0
specs/009-login-rate-limiting/SHIPPED
Normal file
0
specs/010-api-prod-dockerfile/SHIPPED
Normal file
0
specs/010-api-prod-dockerfile/SHIPPED
Normal file
0
specs/011-ui-prod-dockerfile/SHIPPED
Normal file
0
specs/011-ui-prod-dockerfile/SHIPPED
Normal file
0
specs/012-api-docs-gate/SHIPPED
Normal file
0
specs/012-api-docs-gate/SHIPPED
Normal file
0
specs/013-k8s-manifests/SHIPPED
Normal file
0
specs/013-k8s-manifests/SHIPPED
Normal file
0
specs/014-r2-cdn-serving/SHIPPED
Normal file
0
specs/014-r2-cdn-serving/SHIPPED
Normal file
0
specs/015-library-pagination/SHIPPED
Normal file
0
specs/015-library-pagination/SHIPPED
Normal file
0
specs/016-copy-url-toast/SHIPPED
Normal file
0
specs/016-copy-url-toast/SHIPPED
Normal file
0
specs/017-short-id-migration/SHIPPED
Normal file
0
specs/017-short-id-migration/SHIPPED
Normal file
34
specs/017-short-id-migration/checklists/requirements.md
Normal file
34
specs/017-short-id-migration/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Short Image IDs
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-05-09
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [X] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [X] Focused on user value and business needs
|
||||||
|
- [X] Written for non-technical stakeholders
|
||||||
|
- [X] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [X] Requirements are testable and unambiguous
|
||||||
|
- [X] Success criteria are measurable
|
||||||
|
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [X] All acceptance scenarios are defined
|
||||||
|
- [X] Edge cases are identified
|
||||||
|
- [X] Scope is clearly bounded
|
||||||
|
- [X] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [X] All functional requirements have clear acceptance criteria
|
||||||
|
- [X] User scenarios cover primary flows
|
||||||
|
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [X] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. Ready for /speckit-plan.
|
||||||
115
specs/017-short-id-migration/contracts/image-api.md
Normal file
115
specs/017-short-id-migration/contracts/image-api.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Contract: Image API (Short ID Update)
|
||||||
|
|
||||||
|
## ImageRecord Response Schema
|
||||||
|
|
||||||
|
All image endpoints return this shape. `short_id` is a new field; all other fields are unchanged.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "7343d164-80bb-473b-b239-717f2842ae4e",
|
||||||
|
"short_id": "xK7mN2pQ",
|
||||||
|
"hash": "163dec08460650439f1e7439721e8e566aff7d8aaad60cf451e7d3518a334a23",
|
||||||
|
"filename": "image.gif",
|
||||||
|
"mime_type": "image/gif",
|
||||||
|
"size_bytes": 1957149,
|
||||||
|
"width": 265,
|
||||||
|
"height": 199,
|
||||||
|
"storage_key": "xK7mN2pQ",
|
||||||
|
"thumbnail_key": "xK7mN2pQ-thumb",
|
||||||
|
"file_url": "https://cdn.reactbin.juggalol.com/xK7mN2pQ",
|
||||||
|
"thumbnail_url": "https://cdn.reactbin.juggalol.com/xK7mN2pQ-thumb",
|
||||||
|
"created_at": "2026-05-09T02:46:29.520296+00:00",
|
||||||
|
"tags": ["kfc"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Constraints**:
|
||||||
|
- `short_id`: exactly 8 alphanumeric characters `[a-zA-Z0-9]{8}`
|
||||||
|
- `storage_key`: equals `short_id` (post-migration)
|
||||||
|
- `thumbnail_key`: equals `{short_id}-thumb` or `null` if no thumbnail exists
|
||||||
|
- `file_url`: `{cdn_base}/{short_id}` when CDN is configured; `/api/v1/images/{short_id}/file` otherwise
|
||||||
|
- `thumbnail_url`: `{cdn_base}/{short_id}-thumb` or `null`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route Changes
|
||||||
|
|
||||||
|
All routes that previously accepted `{image_id}` as a UUID now accept `{short_id}` as an 8-character alphanumeric string.
|
||||||
|
|
||||||
|
### GET /api/v1/images/{short_id}
|
||||||
|
|
||||||
|
Fetch a single image by short ID.
|
||||||
|
|
||||||
|
- **Path param**: `short_id` — 8-char alphanumeric string
|
||||||
|
- **Response 200**: ImageRecord
|
||||||
|
- **Response 404**: `{"detail": "Image not found", "code": "image_not_found"}`
|
||||||
|
- **Response 422**: `{"detail": "Invalid image ID", "code": "invalid_short_id"}` if param is not 8 alphanumeric chars
|
||||||
|
|
||||||
|
### PATCH /api/v1/images/{short_id}/tags
|
||||||
|
|
||||||
|
Update tags on an image. Auth required.
|
||||||
|
|
||||||
|
- **Path param**: `short_id` — 8-char alphanumeric string
|
||||||
|
- **Body**: `{"tags": ["tag1", "tag2"]}`
|
||||||
|
- **Response 200**: ImageRecord (updated)
|
||||||
|
- **Response 404/422**: same shape as above
|
||||||
|
|
||||||
|
### DELETE /api/v1/images/{short_id}
|
||||||
|
|
||||||
|
Delete an image and its storage objects. Auth required.
|
||||||
|
|
||||||
|
- **Path param**: `short_id` — 8-char alphanumeric string
|
||||||
|
- **Response 204**: no body
|
||||||
|
- **Response 404**: error envelope
|
||||||
|
|
||||||
|
### GET /api/v1/images/{short_id}/file
|
||||||
|
|
||||||
|
Serve the raw image file (proxy mode, when CDN is not configured).
|
||||||
|
|
||||||
|
- **Path param**: `short_id`
|
||||||
|
- **Response 200**: raw image bytes with correct `Content-Type`
|
||||||
|
|
||||||
|
### GET /api/v1/images/{short_id}/thumbnail
|
||||||
|
|
||||||
|
Serve the thumbnail (proxy mode).
|
||||||
|
|
||||||
|
- **Path param**: `short_id`
|
||||||
|
- **Response 200**: WebP bytes or original image if no thumbnail
|
||||||
|
|
||||||
|
### POST /api/v1/images (upload — unchanged route, updated response)
|
||||||
|
|
||||||
|
- **Response**: ImageRecord with `short_id` populated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Route Change
|
||||||
|
|
||||||
|
| Old route | New route |
|
||||||
|
|-----------------|--------------|
|
||||||
|
| `/images/:id` | `/i/:id` |
|
||||||
|
|
||||||
|
The `:id` segment now contains the `short_id` value (8 alphanumeric chars) rather than a UUID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ImageRecord TypeScript Interface (updated)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ImageRecord {
|
||||||
|
id: string; // UUID — retained, not used for routing
|
||||||
|
short_id: string; // NEW — 8-char base62, used for all routing and API calls
|
||||||
|
hash: string;
|
||||||
|
filename: string;
|
||||||
|
mime_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
storage_key: string;
|
||||||
|
thumbnail_key: string | null;
|
||||||
|
file_url: string;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
created_at: string;
|
||||||
|
tags: string[];
|
||||||
|
duplicate?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
77
specs/017-short-id-migration/data-model.md
Normal file
77
specs/017-short-id-migration/data-model.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Data Model: Short Image IDs
|
||||||
|
|
||||||
|
## Changed Entity: Image
|
||||||
|
|
||||||
|
### New Column
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Notes |
|
||||||
|
|------------|--------------|------------------------------|-------------------------------------------|
|
||||||
|
| `short_id` | VARCHAR(8) | UNIQUE, NOT NULL (post-migration), INDEX | Base62 alphanumeric, 8 characters |
|
||||||
|
|
||||||
|
### Updated Columns (values change, types unchanged)
|
||||||
|
|
||||||
|
| Column | Old values | New values |
|
||||||
|
|-----------------|-----------------------------------------|-----------------------------------|
|
||||||
|
| `storage_key` | SHA-256 hash (64 hex chars) | short_id (8 base62 chars) |
|
||||||
|
| `thumbnail_key` | `{hash}-thumb` (69 chars) | `{short_id}-thumb` (13 chars) |
|
||||||
|
|
||||||
|
### Unchanged Columns
|
||||||
|
|
||||||
|
| Column | Notes |
|
||||||
|
|------------|-----------------------------------------------------------------------|
|
||||||
|
| `id` | UUID primary key — unchanged, retained as internal identifier |
|
||||||
|
| `hash` | SHA-256 content hash — unchanged, still used for deduplication |
|
||||||
|
| `filename` | Unchanged |
|
||||||
|
| `mime_type`| Unchanged |
|
||||||
|
| `size_bytes`, `width`, `height` | Unchanged |
|
||||||
|
| `created_at` | Unchanged |
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- `short_id`: exactly 8 characters, matching `[a-zA-Z0-9]{8}` — generated on insert, never updated
|
||||||
|
- `short_id` must be unique across all image records
|
||||||
|
- On collision (rare), a new value is generated and retried (up to 10 attempts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alembic Migrations
|
||||||
|
|
||||||
|
### Migration 003 — Add `short_id` column (nullable)
|
||||||
|
|
||||||
|
```
|
||||||
|
ALTER TABLE images ADD COLUMN short_id VARCHAR(8) NULL;
|
||||||
|
CREATE UNIQUE INDEX ix_images_short_id ON images (short_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
Run immediately on deploy. Existing rows get `short_id = NULL`. New uploads will populate `short_id` on insert (application-level).
|
||||||
|
|
||||||
|
### Migration Script — Backfill existing rows
|
||||||
|
|
||||||
|
`api/scripts/migrate_to_short_ids.py`
|
||||||
|
|
||||||
|
For each image where `short_id IS NULL`:
|
||||||
|
1. Generate 8-char base62 short_id (retry on collision)
|
||||||
|
2. Copy storage object: `{hash}` → `{short_id}` (S3 copy)
|
||||||
|
3. Copy thumbnail if present: `{hash}-thumb` → `{short_id}-thumb`
|
||||||
|
4. Verify new objects exist (S3 head_object)
|
||||||
|
5. Update DB row: `short_id = {short_id}`, `storage_key = {short_id}`, `thumbnail_key = {short_id}-thumb` (or NULL)
|
||||||
|
6. Delete old storage objects
|
||||||
|
|
||||||
|
### Migration 004 — Add NOT NULL constraint
|
||||||
|
|
||||||
|
```
|
||||||
|
ALTER TABLE images ALTER COLUMN short_id SET NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
Run only after the migration script completes successfully with zero `short_id IS NULL` rows remaining.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage Object Naming Convention
|
||||||
|
|
||||||
|
| Object type | Key pattern | Example |
|
||||||
|
|-------------|---------------------|-------------------|
|
||||||
|
| Original | `{short_id}` | `xK7mN2pQ` |
|
||||||
|
| Thumbnail | `{short_id}-thumb` | `xK7mN2pQ-thumb` |
|
||||||
|
|
||||||
|
No folder structure. Flat bucket layout (unchanged from current convention).
|
||||||
198
specs/017-short-id-migration/plan.md
Normal file
198
specs/017-short-id-migration/plan.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Implementation Plan: Short Image IDs
|
||||||
|
|
||||||
|
**Branch**: `017-short-id-migration` | **Date**: 2026-05-09 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `specs/017-short-id-migration/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace hash-based storage keys and UUID-based URL routing with 8-character base62 short IDs. The short ID becomes the canonical identifier in URLs (`/i/:short_id`), storage keys (`{short_id}` / `{short_id}-thumb`), and all API responses. Hash-based deduplication is preserved unchanged. A Python migration script handles existing images: generates short IDs, copies storage objects to new keys, updates DB records, deletes old keys.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Python 3.12+ (API), TypeScript strict (UI)
|
||||||
|
**Primary Dependencies**: FastAPI, SQLAlchemy 2.x async, Alembic, aiobotocore/boto3, Angular (latest stable)
|
||||||
|
**Storage**: PostgreSQL (DB), S3-compatible via boto3 (MinIO local / CDN in prod)
|
||||||
|
**Testing**: pytest + pytest-asyncio (API unit + integration), Karma/Jasmine (Angular)
|
||||||
|
**Target Platform**: Linux server (k3s), browser SPA
|
||||||
|
**Project Type**: Web application (FastAPI API + Angular SPA)
|
||||||
|
**Performance Goals**: Migration script should process all existing images without timeout; no user-facing performance change
|
||||||
|
**Constraints**: Migration must be idempotent; no data loss; copy-before-delete for all storage operations
|
||||||
|
**Scale/Scope**: Personal collection (~hundreds to low thousands of images); collision probability negligible
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before implementation.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| §2.5 DB abstraction — all queries through repos | ✅ PASS | New `get_by_short_id()` added to `ImageRepository`; no raw SQL outside repo |
|
||||||
|
| §2.6 No speculative abstraction | ✅ PASS | `generate_short_id()` is a concrete utility; no new interfaces |
|
||||||
|
| §3.1 Routes prefixed `/api/v1/` | ✅ PASS | All routes remain under `/api/v1/images/` |
|
||||||
|
| §3.1 Adding fields is non-breaking | ✅ PASS | `short_id` is additive; `id` UUID retained |
|
||||||
|
| §4.2 Images immutable after upload | ✅ PASS | File content is copied, not replaced; the operation changes the storage key, not the bytes |
|
||||||
|
| §4.3 Deduplication by content hash | ✅ PASS | `hash` column retained; `get_by_hash` unchanged |
|
||||||
|
| §5.1 Tests alongside every implementation task | ✅ PASS | Each task includes tests |
|
||||||
|
| §5.2 Integration tests use real PostgreSQL + MinIO | ✅ PASS | Existing integration test infrastructure reused |
|
||||||
|
| §8 Scope boundaries | ✅ PASS | No multi-user, no public sharing feature, no OR/NOT tag logic |
|
||||||
|
|
||||||
|
**No violations. Implementation may proceed.**
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/017-short-id-migration/
|
||||||
|
├── plan.md ← this file
|
||||||
|
├── research.md ← short ID generation, migration strategy
|
||||||
|
├── data-model.md ← Image schema changes, Alembic migrations
|
||||||
|
├── contracts/
|
||||||
|
│ └── image-api.md ← updated ImageRecord schema, route changes
|
||||||
|
├── quickstart.md ← manual test scenarios
|
||||||
|
└── tasks.md ← generated by /speckit-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code Changes
|
||||||
|
|
||||||
|
```text
|
||||||
|
api/
|
||||||
|
├── app/
|
||||||
|
│ ├── models.py # Add Image.short_id column
|
||||||
|
│ ├── utils.py # Add generate_short_id()
|
||||||
|
│ ├── repositories/
|
||||||
|
│ │ └── image_repo.py # Add get_by_short_id(), update create()
|
||||||
|
│ └── routers/
|
||||||
|
│ └── images.py # Path params uuid→str, add short_id to response
|
||||||
|
├── alembic/versions/
|
||||||
|
│ ├── 003_add_short_id.py # ADD COLUMN short_id VARCHAR(8) NULLABLE UNIQUE
|
||||||
|
│ └── 004_short_id_not_null.py # SET NOT NULL (run after migration script)
|
||||||
|
├── scripts/
|
||||||
|
│ └── migrate_to_short_ids.py # Backfill existing images
|
||||||
|
└── tests/
|
||||||
|
├── unit/
|
||||||
|
│ ├── test_hashing.py # Add generate_short_id() tests
|
||||||
|
│ ├── test_url_construction.py # Update mock images to include short_id
|
||||||
|
│ └── test_short_id.py # NEW: collision retry, charset validation
|
||||||
|
└── integration/
|
||||||
|
├── test_upload.py # Assert short_id in response
|
||||||
|
├── test_search.py # Update {id} → {short_id} in route calls
|
||||||
|
├── test_delete.py # Update route params
|
||||||
|
├── test_serving.py # Update route params
|
||||||
|
└── test_tags.py # Update route params
|
||||||
|
|
||||||
|
ui/src/app/
|
||||||
|
├── app.routes.ts # 'images/:id' → 'i/:id'
|
||||||
|
├── services/
|
||||||
|
│ └── image.service.ts # Add short_id to ImageRecord, update service calls
|
||||||
|
├── library/
|
||||||
|
│ └── library.component.ts # Navigate to ['/i', img.short_id]
|
||||||
|
├── upload/
|
||||||
|
│ └── upload.component.ts # Navigate to ['/i', res.short_id] after upload
|
||||||
|
└── detail/
|
||||||
|
└── detail.component.ts # (no route change needed; reads :id param same way)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Existing web application layout. API changes are concentrated in models, repository, router, and a new migration script. UI changes are confined to routes, image service interface, and two navigation calls.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Backend — Short ID Infrastructure
|
||||||
|
|
||||||
|
1. Add `generate_short_id()` to `api/app/utils.py`
|
||||||
|
- Base62 charset: `string.ascii_letters + string.digits`
|
||||||
|
- Uses `secrets.choice` for cryptographic randomness
|
||||||
|
- Returns 8-character string
|
||||||
|
|
||||||
|
2. Add Alembic migration `003_add_short_id.py`
|
||||||
|
- `ADD COLUMN short_id VARCHAR(8) NULL`
|
||||||
|
- `CREATE UNIQUE INDEX ix_images_short_id ON images (short_id)`
|
||||||
|
|
||||||
|
3. Update `api/app/models.py`
|
||||||
|
- Add `short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)`
|
||||||
|
|
||||||
|
4. Update `api/app/repositories/image_repo.py`
|
||||||
|
- Add `get_by_short_id(short_id: str) -> Image | None`
|
||||||
|
- Update `create()` to accept and persist `short_id` parameter
|
||||||
|
|
||||||
|
5. Update `api/app/routers/images.py`
|
||||||
|
- Change all `image_id: uuid.UUID` path params to `short_id: str`
|
||||||
|
- Add `_validate_short_id(short_id: str)` helper (8 alphanumeric chars, else 422)
|
||||||
|
- Replace `get_by_id` calls with `get_by_short_id`
|
||||||
|
- Update `_image_to_dict` to include `"short_id": image.short_id` in response
|
||||||
|
- Update upload handler: generate `short_id` with collision retry, use as storage key
|
||||||
|
|
||||||
|
### Phase 2: Migration Script
|
||||||
|
|
||||||
|
`api/scripts/migrate_to_short_ids.py`:
|
||||||
|
|
||||||
|
```
|
||||||
|
for each image where short_id IS NULL:
|
||||||
|
generate short_id (retry on DB collision)
|
||||||
|
copy {hash} → {short_id} in storage
|
||||||
|
if thumbnail_key IS NOT NULL:
|
||||||
|
copy {hash}-thumb → {short_id}-thumb in storage
|
||||||
|
verify new objects exist (head_object)
|
||||||
|
UPDATE images SET short_id={sid}, storage_key={sid}, thumbnail_key={sid}-thumb WHERE id={id}
|
||||||
|
delete {hash} from storage
|
||||||
|
if thumbnail_key was not null:
|
||||||
|
delete {hash}-thumb from storage
|
||||||
|
log: "migrated {id} → {short_id}"
|
||||||
|
|
||||||
|
print summary: N migrated, M skipped (already had short_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
After script runs with 0 remaining `NULL` short_ids, apply migration `004_short_id_not_null.py`.
|
||||||
|
|
||||||
|
### Phase 3: Frontend
|
||||||
|
|
||||||
|
1. `app.routes.ts`: `path: 'images/:id'` → `path: 'i/:id'`
|
||||||
|
2. `image.service.ts`: add `short_id: string` to `ImageRecord`
|
||||||
|
3. `library.component.ts`: `router.navigate(['/images', img.id])` → `router.navigate(['/i', img.short_id])`
|
||||||
|
4. `upload.component.ts`: `router.navigate(['/images', res.id])` → `router.navigate(['/i', res.short_id])`
|
||||||
|
|
||||||
|
### Phase 4: Polish
|
||||||
|
|
||||||
|
- Update all existing API integration tests to use `short_id` in route paths
|
||||||
|
- Run `ng lint` and `ruff check` across modified files
|
||||||
|
- Verify `ng build --configuration production` succeeds
|
||||||
|
- Run full test suites: `make test-unit && make test-integration`
|
||||||
|
|
||||||
|
## Key Implementation Notes
|
||||||
|
|
||||||
|
### Collision Retry Pattern (upload)
|
||||||
|
|
||||||
|
```python
|
||||||
|
MAX_RETRIES = 10
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
short_id = generate_short_id()
|
||||||
|
try:
|
||||||
|
image = await image_repo.create(..., short_id=short_id)
|
||||||
|
break
|
||||||
|
except IntegrityError: # short_id collision
|
||||||
|
await db.rollback()
|
||||||
|
if attempt == MAX_RETRIES - 1:
|
||||||
|
raise RuntimeError("Could not generate unique short_id")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route Validation
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re
|
||||||
|
_SHORT_ID_RE = re.compile(r'^[a-zA-Z0-9]{8}$')
|
||||||
|
|
||||||
|
def _validate_short_id(short_id: str) -> None:
|
||||||
|
if not _SHORT_ID_RE.match(short_id):
|
||||||
|
raise HTTPException(422, detail={"detail": "Invalid image ID", "code": "invalid_short_id"})
|
||||||
|
```
|
||||||
|
|
||||||
|
### `_image_to_dict` Update
|
||||||
|
|
||||||
|
Add `"short_id": image.short_id` to the returned dict. The `file_url` and `thumbnail_url` generation already uses `image.storage_key` which will now equal `image.short_id` — no formula change needed.
|
||||||
|
|
||||||
|
### Migration Script Entry Point
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api && python -m scripts.migrate_to_short_ids
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads DB URL and storage config from environment variables (same as the application).
|
||||||
73
specs/017-short-id-migration/quickstart.md
Normal file
73
specs/017-short-id-migration/quickstart.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Quickstart: Short Image IDs
|
||||||
|
|
||||||
|
## Scenario 1 — Happy Path: New Upload Gets Short ID
|
||||||
|
|
||||||
|
1. Log in and navigate to Upload.
|
||||||
|
2. Upload any image.
|
||||||
|
3. Observe: browser navigates to `/i/AbCdEfGh` (8-char short ID, not a UUID).
|
||||||
|
4. Copy the URL from the address bar and paste in a new tab — image loads correctly.
|
||||||
|
5. Open the URL in a private/incognito window (not logged in) — image still loads.
|
||||||
|
|
||||||
|
**Pass criteria**: URL is `/i/{8 alphanumeric chars}`, image loads authenticated and unauthenticated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 2 — Deduplication Still Works
|
||||||
|
|
||||||
|
1. Upload any image — note the short ID in the URL.
|
||||||
|
2. Upload the exact same file again.
|
||||||
|
3. Observe: API returns `duplicate: true`, browser navigates to the same short ID URL as step 1.
|
||||||
|
|
||||||
|
**Pass criteria**: No second record created, same short ID returned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 3 — Library Navigation Uses Short IDs
|
||||||
|
|
||||||
|
1. Open the library (`/`).
|
||||||
|
2. Click any image card.
|
||||||
|
3. Observe: navigated to `/i/{short_id}`, not `/images/{uuid}`.
|
||||||
|
|
||||||
|
**Pass criteria**: All image card clicks navigate to `/i/` routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 4 — Tag and Delete Operations Work via Short ID
|
||||||
|
|
||||||
|
1. Open an image detail page at `/i/{short_id}`.
|
||||||
|
2. If logged in: add a tag, remove a tag — confirm both succeed.
|
||||||
|
3. If logged in: delete the image — confirm navigates back to library, image no longer appears.
|
||||||
|
|
||||||
|
**Pass criteria**: Tag updates and delete work correctly when the route uses a short ID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 5 — Migration: All Existing Images Accessible
|
||||||
|
|
||||||
|
1. After running the migration script: open the library.
|
||||||
|
2. Click through several images from before the migration.
|
||||||
|
3. Observe: all navigate to `/i/{short_id}` URLs, all images and thumbnails load.
|
||||||
|
4. No broken image placeholders visible.
|
||||||
|
|
||||||
|
**Pass criteria**: 100% of pre-migration images accessible via short ID with no broken assets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 6 — Migration Script Is Idempotent
|
||||||
|
|
||||||
|
1. Run the migration script once — note how many images were migrated.
|
||||||
|
2. Run the migration script a second time.
|
||||||
|
3. Observe: script reports 0 images migrated (all already have short IDs), exits cleanly.
|
||||||
|
|
||||||
|
**Pass criteria**: Second run produces no DB changes, no storage operations, no errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 7 — Copy URL Button Copies Short Page URL
|
||||||
|
|
||||||
|
1. Open any image detail page at `/i/{short_id}`.
|
||||||
|
2. Click "Copy URL".
|
||||||
|
3. Paste into a text editor.
|
||||||
|
4. Observe: pasted value is the CDN file URL (e.g. `https://cdn.reactbin.juggalol.com/xK7mN2pQ`), not a UUID-based URL.
|
||||||
|
|
||||||
|
**Pass criteria**: Copied URL contains the short_id, not a UUID.
|
||||||
56
specs/017-short-id-migration/research.md
Normal file
56
specs/017-short-id-migration/research.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Research: Short Image IDs
|
||||||
|
|
||||||
|
## Short ID Generation
|
||||||
|
|
||||||
|
**Decision**: Use `secrets.choice` over `string.ascii_letters + string.digits` (base62, 62 characters), 8 characters long.
|
||||||
|
|
||||||
|
**Rationale**: `secrets.choice` is cryptographically random, eliminating any bias from modular reduction that affects simpler approaches. Base62 (a–z, A–Z, 0–9) is URL-safe without percent-encoding. 8 characters gives 62⁸ ≈ 218 trillion combinations — negligible collision probability even at millions of images.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `secrets.token_urlsafe(6)` — includes `-` and `_`, not pure alphanumeric
|
||||||
|
- UUID truncation (first 8 chars of hex) — only 16 chars of alphabet (hex), dramatically fewer combinations than base62
|
||||||
|
- nanoid (npm) — JavaScript library, requires a separate dependency for Python
|
||||||
|
|
||||||
|
**Collision retry**: On insert, if a `UniqueConstraint` violation is raised on `short_id`, generate a new one and retry (up to a configurable limit, e.g., 10 attempts). At 10,000 images the per-attempt collision probability is ~4.6 × 10⁻¹¹; retries are a pure safety measure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alembic Two-Phase Migration Strategy
|
||||||
|
|
||||||
|
**Decision**: Two separate Alembic migrations (003 + 004), with the Python migration script run between them.
|
||||||
|
|
||||||
|
**Rationale**: The `short_id` column must start nullable so existing rows can be inserted without a value. The migration script fills all existing rows. Once confirmed, a second migration adds the NOT NULL constraint. Running both as one migration would require a complex inline Python script in Alembic (fragile, untestable). Two migrations with a script in between is the standard approach for backfill + constraint change.
|
||||||
|
|
||||||
|
**Migration 003**: `ADD COLUMN short_id VARCHAR(8) NULL UNIQUE` + GiST/B-tree index.
|
||||||
|
**Script**: Fill all rows, idempotent (skip rows where `short_id IS NOT NULL`).
|
||||||
|
**Migration 004**: `ALTER COLUMN short_id SET NOT NULL`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage Object Copy Strategy
|
||||||
|
|
||||||
|
**Decision**: Copy-then-verify-then-delete (not atomic rename). Using the MinIO/S3 `copy_object` API followed by a `delete_object` call.
|
||||||
|
|
||||||
|
**Rationale**: S3-compatible object stores do not support atomic renames. The safe approach is: copy to new key, verify new object exists (head_object), update DB, delete old key. If interrupted after copy but before delete, the old object remains — wasted storage but no data loss. The migration is idempotent: if `short_id` is already set on a row, the script skips it.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `mc mv` (MinIO client CLI) — simpler but harder to script transactionally with DB updates
|
||||||
|
- Direct Python with `aiobotocore` — chosen; same library already used by the storage backend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Route Parameter Change
|
||||||
|
|
||||||
|
**Decision**: Change all image route parameters from `image_id: uuid.UUID` to `short_id: str` with manual length/charset validation.
|
||||||
|
|
||||||
|
**Rationale**: FastAPI's `uuid.UUID` type annotation rejects non-UUID strings at the path-parsing stage, so the existing routes cannot accept short IDs without a type change. Switching to `str` with a custom validator (8 alphanumeric chars) is minimal and clear.
|
||||||
|
|
||||||
|
**Impact**: All routes under `/api/v1/images/{id}` change to accept an 8-char string. The `id` field in API responses is retained as the UUID; `short_id` is added as a new field. The UI switches to using `short_id` for all navigation and API calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Schema: Additive Change
|
||||||
|
|
||||||
|
**Decision**: Add `short_id` as a new field to the image response dict. The existing `id` (UUID) field is retained.
|
||||||
|
|
||||||
|
**Rationale**: Adding a field is non-breaking per §3.1. Removing `id` would be a breaking change. Retaining both allows any internal tooling or API consumers that already use `id` to continue working. The UI transitions to using `short_id` for routing and API calls, but the UUID remains queryable if needed.
|
||||||
104
specs/017-short-id-migration/spec.md
Normal file
104
specs/017-short-id-migration/spec.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Feature Specification: Short Image IDs
|
||||||
|
|
||||||
|
**Feature Branch**: `017-short-id-migration`
|
||||||
|
**Created**: 2026-05-09
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Replace UUID-based image identifiers with 8-character base62 short IDs. Short IDs become the canonical identifier in URLs (/i/:short_id replacing /images/:uuid), MinIO storage keys, and all API responses. Existing hash-based deduplication is preserved. Migration includes backfilling short IDs for existing images, renaming storage objects, and regenerating file URLs. Frontend routes update to use short IDs throughout."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 — Clean, Shareable Image Links (Priority: P1)
|
||||||
|
|
||||||
|
A user wants to share an image with someone. They copy the page URL or use the "Copy URL" button and get a short, clean link they can paste anywhere. The link is brief enough to share in a message without looking like machine-generated noise.
|
||||||
|
|
||||||
|
**Why this priority**: This is the primary user-facing value of the change. Every image in the library benefits immediately. Short links are more trustworthy, easier to share, and less likely to break in messaging apps that truncate long URLs.
|
||||||
|
|
||||||
|
**Independent Test**: Open any image detail page. Confirm the URL in the browser address bar is short (e.g. `/i/AbCdEfGh`). Copy the URL and paste it into a new tab — confirm the correct image loads. Share the link with someone who is not logged in and confirm they can view the image.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user is on the image detail page, **When** they look at the browser address bar, **Then** the URL contains a short 8-character identifier rather than a long UUID.
|
||||||
|
2. **Given** a short image URL, **When** an unauthenticated user opens it, **Then** the image loads correctly without requiring login.
|
||||||
|
3. **Given** a short image URL, **When** it is pasted into a messaging app or email, **Then** it is compact enough to read at a glance and does not get truncated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 — New Uploads Assigned Short IDs (Priority: P2)
|
||||||
|
|
||||||
|
When a new image is uploaded, the system assigns it a short ID immediately. The image is accessible via its short URL straight away. If the same file has already been uploaded before, the existing record is returned rather than creating a duplicate — the deduplication behaviour is unchanged.
|
||||||
|
|
||||||
|
**Why this priority**: This ensures the new convention is in place going forward. Without this, the migration work in US3 would need to be re-run for any new uploads.
|
||||||
|
|
||||||
|
**Independent Test**: Upload a new image. Confirm the detail page URL contains an 8-character short ID. Upload the exact same file again — confirm no new record is created and the existing short URL is returned.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user uploads an image, **When** the upload completes, **Then** the image is accessible at a short URL (`/i/{short_id}`).
|
||||||
|
2. **Given** a user uploads a file that is identical to a previously uploaded image, **When** the upload completes, **Then** the system returns the existing image's short URL rather than creating a duplicate entry.
|
||||||
|
3. **Given** a newly uploaded image, **When** the "Copy URL" button is used, **Then** the copied link is the short image page URL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 — All Existing Images Migrated to Short IDs (Priority: P3)
|
||||||
|
|
||||||
|
All images that existed before this change are assigned short IDs and remain fully accessible. Their stored files are renamed to match the new convention. After migration, all image links throughout the application use short IDs — no UUID-based links remain active.
|
||||||
|
|
||||||
|
**Why this priority**: Without migration, legacy images would either be inaccessible or require maintaining two parallel URL schemes. Clean cutover is preferable. This is lower priority than P1/P2 because it is an administrative operation rather than a user-facing feature, but it must complete before the feature can be considered fully shipped.
|
||||||
|
|
||||||
|
**Independent Test**: After running the migration, browse the library and open several images — confirm all detail pages use short URLs. Confirm no broken images or missing thumbnails.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** images that existed before the migration, **When** the migration completes, **Then** all are accessible via short URLs.
|
||||||
|
2. **Given** the migration has run, **When** a user browses the library and opens any image, **Then** the detail page URL is a short ID URL.
|
||||||
|
3. **Given** the migration has run, **When** any image or thumbnail is displayed, **Then** it loads correctly with no broken images.
|
||||||
|
4. **Given** the migration is running, **When** it encounters an error on one image, **Then** it reports the failure clearly and continues processing remaining images rather than aborting entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens if a short ID collision occurs during generation? The system must retry with a new ID rather than failing or overwriting an existing image.
|
||||||
|
- What happens if a record lacks a short ID but the file content is unchanged? The migration assigns a new short ID without re-uploading the file.
|
||||||
|
- What happens if the migration is interrupted partway through? Already-migrated images remain accessible; un-migrated images are identifiable so the migration can be re-run safely.
|
||||||
|
- What happens if a thumbnail does not exist for an image (e.g., GIFs where generation failed)? The migration skips the thumbnail rename for that record and continues.
|
||||||
|
- What happens if a user has bookmarked a UUID-based URL before the migration? Those URLs become invalid; this is acceptable for a personal tool with no external consumers.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST assign every image a unique 8-character short ID composed of alphanumeric characters (a–z, A–Z, 0–9).
|
||||||
|
- **FR-002**: Every image detail page MUST be accessible at the path `/i/{short_id}`.
|
||||||
|
- **FR-003**: The UUID-based image detail route (`/images/{uuid}`) MUST be retired; short ID routes are the sole canonical paths.
|
||||||
|
- **FR-004**: Image storage objects (original and thumbnail) MUST use the short ID as their storage key, following flat naming: `{short_id}` for the original and `{short_id}-thumb` for the thumbnail.
|
||||||
|
- **FR-005**: The publicly accessible image file URL and thumbnail URL MUST reflect the new storage key names.
|
||||||
|
- **FR-006**: On upload, the system MUST check whether an identical file (by hash) already exists and return the existing record rather than creating a duplicate, regardless of short IDs.
|
||||||
|
- **FR-007**: The system MUST generate a new short ID on upload, retrying automatically if a collision with an existing ID is detected.
|
||||||
|
- **FR-008**: A migration process MUST assign short IDs to all existing images that do not have one, rename their storage objects to match the new keys, and update all stored URLs.
|
||||||
|
- **FR-009**: The migration MUST be re-runnable safely — images already migrated MUST be skipped rather than processed again.
|
||||||
|
- **FR-010**: All application links that reference images (library grid, detail page, API responses) MUST use short IDs after the migration.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Image**: Each image has a unique short ID (8 alphanumeric characters) that serves as its canonical identifier in URLs, storage, and API responses. The image retains its content hash for deduplication. The short ID is independent of the hash.
|
||||||
|
- **Storage Object**: Each image has two storage objects — an original and a thumbnail — named using the short ID (`{short_id}` and `{short_id}-thumb`). Flat naming, no folder structure.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: All image detail page URLs use an 8-character alphanumeric identifier at `/i/{short_id}`.
|
||||||
|
- **SC-002**: 100% of existing images are accessible via short URL after migration completes, with no broken images or missing thumbnails.
|
||||||
|
- **SC-003**: Uploading the same file twice produces one record — deduplication rate remains 100% for identical files.
|
||||||
|
- **SC-004**: The migration completes without data loss — no image file or thumbnail is deleted before its renamed copy is confirmed present in storage.
|
||||||
|
- **SC-005**: The migration is idempotent — running it a second time produces no changes and no errors.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- UUID-based image URLs do not need to remain accessible after migration; this is a personal tool with no external consumers relying on the old URL structure.
|
||||||
|
- The migration will be run manually by the operator as a one-time administrative step; it does not need to be triggered from the UI.
|
||||||
|
- Storage object renaming is implemented as copy-then-delete to avoid data loss if the process is interrupted mid-run.
|
||||||
|
- The short ID character set is base62 (a–z, A–Z, 0–9); no special characters, ensuring URL-safe identifiers without percent-encoding.
|
||||||
|
- The `hash` column is retained and continues to be used for deduplication; it is not removed as part of this change.
|
||||||
|
- Thumbnails may not exist for all images (e.g., some GIFs); the migration handles missing thumbnails gracefully by skipping the thumbnail rename for those records.
|
||||||
162
specs/017-short-id-migration/tasks.md
Normal file
162
specs/017-short-id-migration/tasks.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Tasks: Short Image IDs
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/017-short-id-migration/`
|
||||||
|
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/image-api.md ✅, quickstart.md ✅
|
||||||
|
|
||||||
|
**Tests**: Tests accompany each implementation task per §5.1. All API changes are in `api/`, all UI changes are in `ui/src/app/`.
|
||||||
|
|
||||||
|
**Organization**: The foundational phase (Phase 1) must complete before any user story work begins — it adds the `short_id` column, model field, utility function, and repository method that all three user stories depend on. US1 and US2 can then proceed; US3 (migration script) follows last because it operates on the fully wired system.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Foundational — Short ID Infrastructure (Blocks All User Stories)
|
||||||
|
|
||||||
|
**Goal**: Add the `short_id` column to the database, the model, a generation utility, and a repository lookup. Every user story depends on these.
|
||||||
|
|
||||||
|
**Independent Test**: After this phase, `generate_short_id()` can be called from a Python shell and returns an 8-character alphanumeric string. `alembic upgrade head` applies migration 003 cleanly. A manually inserted image with a `short_id` can be fetched by `image_repo.get_by_short_id()` in a test.
|
||||||
|
|
||||||
|
- [X] T001 Write failing unit tests for `generate_short_id()` in `api/tests/unit/test_hashing.py`: (1) returns exactly 8 characters; (2) contains only `[a-zA-Z0-9]` characters; (3) two consecutive calls return different values (collision test); (4) function exists and is importable from `app.utils`. Run `make test-unit` and confirm new tests FAIL.
|
||||||
|
|
||||||
|
- [X] T002 Add `generate_short_id()` to `api/app/utils.py`: import `secrets` and `string`; define `BASE62 = string.ascii_letters + string.digits`; implement `def generate_short_id(length: int = 8) -> str: return ''.join(secrets.choice(BASE62) for _ in range(length))`. Run `make test-unit` and confirm T001 tests pass.
|
||||||
|
|
||||||
|
- [X] T003 Create Alembic migration `api/alembic/versions/003_add_short_id.py`: `op.add_column('images', sa.Column('short_id', sa.String(8), nullable=True))`; `op.create_index('ix_images_short_id', 'images', ['short_id'], unique=True)`. Downgrade removes index then column. Run `alembic upgrade head` in the api container and confirm migration applies cleanly.
|
||||||
|
|
||||||
|
- [X] T004 [P] Add `short_id` field to `Image` model in `api/app/models.py`: `short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)`. No change to column sizes for `storage_key` (keep String(64)) or `thumbnail_key` (keep String(70)) — values will simply be shorter after migration.
|
||||||
|
|
||||||
|
- [X] T005 Update `api/app/repositories/image_repo.py`: (a) add `async def get_by_short_id(self, short_id: str) -> Image | None` — SELECT with `Image.short_id == short_id` and `selectinload(Image.image_tags).selectinload(ImageTag.tag)`; (b) add `short_id: str` parameter to `create()` and persist it on the `Image` instance. Write a unit test in `api/tests/unit/` mocking the session to confirm `get_by_short_id` constructs the correct WHERE clause.
|
||||||
|
|
||||||
|
- [X] T005a Update `api/tests/integration/conftest.py`: wherever test fixtures call `image_repo.create()` or insert image rows directly, add `short_id=generate_short_id()` (import `generate_short_id` from `app.utils`). This ensures all integration test fixture images have a `short_id` value so that tests referencing `image.short_id` in URLs and assertions work correctly. Run `make test-integration` and confirm existing tests still pass (no new failures introduced).
|
||||||
|
|
||||||
|
**Checkpoint**: Short ID infrastructure complete. The `short_id` column exists in DB, `generate_short_id()` works, and the repo can look up images by short_id.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 — Clean, Shareable Image Links (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: All API routes accept `short_id` (8-char string) instead of UUID. `short_id` appears in every API response. The frontend navigates to `/i/:short_id` and the library uses `short_id` for navigation.
|
||||||
|
|
||||||
|
**Independent Test**: Upload a new image (US2 must be done for end-to-end, but US1 can be tested using a fixture image with a known `short_id` inserted directly). Call `GET /api/v1/images/{short_id}` and confirm it returns the correct image with `short_id` in the response. Navigate to `/i/{short_id}` in the browser and confirm the detail page loads.
|
||||||
|
|
||||||
|
- [X] T006 Write failing unit tests in `api/tests/unit/test_url_construction.py`: update `_make_image()` mock to include `short_id = 'AbCd1234'`; add assertions that `_image_to_dict` result includes `"short_id": "AbCd1234"`. Write failing unit tests in `api/tests/unit/test_short_id.py`: (1) `_validate_short_id('AbCd1234')` passes; (2) `_validate_short_id('toolong!!')` raises 422; (3) `_validate_short_id('short')` raises 422; (4) `_validate_short_id('has space!')` raises 422. Run `make test-unit` and confirm new tests FAIL.
|
||||||
|
|
||||||
|
- [X] T007 Update `api/app/routers/images.py` — `_image_to_dict`: add `"short_id": image.short_id` to the returned dict (between `"id"` and `"hash"`). Add `_validate_short_id(short_id: str) -> None` helper: compile `re.compile(r'^[a-zA-Z0-9]{8}$')` at module level; raise `HTTPException(422, detail={"detail": "Invalid image ID", "code": "invalid_short_id"})` if no match. Run `make test-unit` and confirm T006 tests pass.
|
||||||
|
|
||||||
|
- [X] T008 Update all image route handlers in `api/app/routers/images.py` — change every `image_id: uuid.UUID` path parameter to `short_id: str`; call `_validate_short_id(short_id)` at the start of each handler; replace all `image_repo.get_by_id(image_id)` calls with `image_repo.get_by_short_id(short_id)`. Affected routes: `GET /images/{short_id}`, `GET /images/{short_id}/file`, `GET /images/{short_id}/thumbnail`, `PATCH /images/{short_id}/tags`, `DELETE /images/{short_id}`. Remove `import uuid` if no longer used.
|
||||||
|
|
||||||
|
- [X] T009 [P] Write failing Angular tests: (a) in `ui/src/app/services/image.service.ts` — update `MOCK_IMAGE` in `detail.component.spec.ts` and any other spec files to include `short_id: 'AbCd1234'`; (b) in `ui/src/app/library/library.component.spec.ts` — add test asserting that clicking an image card calls `router.navigate` with `['/i', img.short_id]` rather than `['/images', img.id]`. Run `ng test --watch=false` and confirm new tests FAIL.
|
||||||
|
|
||||||
|
- [X] T010 Update `ui/src/app/app.routes.ts`: change `path: 'images/:id'` to `path: 'i/:id'`. The `DetailComponent` reads `this.route.snapshot.paramMap.get('id')` — no change needed there since the param name `:id` is unchanged.
|
||||||
|
|
||||||
|
- [X] T011 Add `short_id: string` to the `ImageRecord` interface in `ui/src/app/services/image.service.ts`. No changes to method signatures — `get(id)`, `updateTags(id, ...)`, and `delete(id)` already accept `string`; callers will now pass `short_id` values instead of UUIDs.
|
||||||
|
|
||||||
|
- [X] T011a Update `ui/src/app/detail/detail.component.ts`: change `this.imageService.updateTags(this.image.id, updated)` (×2, lines ~214 and ~224) and `this.imageService.delete(this.image.id)` (line ~235) to use `this.image.short_id` instead of `this.image.id`. After T008 the API accepts only 8-char short_ids; passing a UUID will trigger a 422. Add assertions to `ui/src/app/detail/detail.component.spec.ts` confirming that `updateTags` and `delete` are called with the `short_id` value (`'AbCd1234'`) not the UUID. Run `ng test --watch=false --include="src/app/detail/detail.component.spec.ts"` and confirm new assertions pass.
|
||||||
|
|
||||||
|
- [X] T012 Update `ui/src/app/library/library.component.ts`: change `router.navigate(['/images', img.id])` (×2: click handler and keydown handler) to `router.navigate(['/i', img.short_id])`. Run `ng test --watch=false` and confirm T009 Angular tests pass.
|
||||||
|
|
||||||
|
**Checkpoint**: US1 complete. API returns `short_id` on every image response. Routes accept short IDs. Library navigates to `/i/{short_id}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 — New Uploads Assigned Short IDs (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: When a new image is uploaded, a short ID is generated, used as the storage key (replacing the hash), and returned in the response. Deduplication by content hash still works.
|
||||||
|
|
||||||
|
**Independent Test**: Upload a new image. Confirm the response includes a `short_id` field with exactly 8 alphanumeric characters. Confirm `storage_key` equals `short_id` and `thumbnail_key` equals `{short_id}-thumb`. Upload the same image again — confirm `duplicate: true` and the same `short_id` is returned.
|
||||||
|
|
||||||
|
- [X] T013 Write failing integration tests in `api/tests/integration/test_upload.py`: (1) upload a new image → response includes `short_id` field matching `[a-zA-Z0-9]{8}`; (2) `storage_key` in response equals `short_id`; (3) `thumbnail_key` in response equals `{short_id}-thumb` (or is null for images without thumbnails); (4) upload same file twice → second response has `duplicate: true` and identical `short_id`. Run `make test-integration` and confirm new tests FAIL.
|
||||||
|
|
||||||
|
- [X] T014 Update the upload handler in `api/app/routers/images.py`: after the hash duplicate check, add collision-retry loop (up to 10 attempts): `short_id = generate_short_id()`; call `await storage.put(short_id, data, mime_type)` instead of `await storage.put(hash_hex, ...)`; call `await storage.put(f"{short_id}-thumb", ...)` instead of `f"{hash_hex}-thumb"`; pass `storage_key=short_id`, `thumbnail_key=f"{short_id}-thumb"` (or None), and `short_id=short_id` to `image_repo.create()`. Catch `IntegrityError` on `create()`, rollback, retry with new short_id. Import `generate_short_id` from `app.utils` and `IntegrityError` from `sqlalchemy.exc`. Run `make test-integration` and confirm T013 tests pass.
|
||||||
|
|
||||||
|
- [X] T015 Update `ui/src/app/upload/upload.component.ts`: change `this.router.navigate(['/images', res.id])` to `this.router.navigate(['/i', res.short_id])`. Add a test to the upload component spec (or update the existing navigation test) asserting the route uses `short_id`.
|
||||||
|
|
||||||
|
**Checkpoint**: US2 complete. All new uploads produce a short ID and are immediately accessible at `/i/{short_id}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 3 — All Existing Images Migrated to Short IDs (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: A runnable script backfills `short_id` for all pre-existing images, copies their storage objects to the new key pattern, and updates DB records. A final Alembic migration adds the NOT NULL constraint. After this phase the system has no UUID-keyed storage objects.
|
||||||
|
|
||||||
|
**Independent Test**: Run the migration script — confirm it prints a count of migrated images and exits cleanly. Run it a second time — confirm it reports 0 migrated (idempotent). Browse the library and open pre-migration images — confirm all load with short ID URLs and no broken images.
|
||||||
|
|
||||||
|
- [X] T016 Write unit tests in `api/tests/unit/test_migration.py` covering the migration script logic: (1) an image with `short_id IS NULL` is processed (short_id generated, storage copy called, DB update called, old keys deleted); (2) an image with `short_id` already set is skipped; (3) if a storage copy fails, the error is logged and the script continues to the next image (no abort); (4) the summary at the end reports correct migrated and skipped counts. Mock the storage client and DB session. Run `make test-unit` and confirm new tests FAIL (script not yet created).
|
||||||
|
|
||||||
|
- [X] T017 Create `api/scripts/__init__.py` (empty) and `api/scripts/migrate_to_short_ids.py`: async main function that (a) reads DB URL and storage config from env vars via `app.config.get_settings()`; (b) creates an async DB session and `S3StorageBackend` instance; (c) queries all images where `short_id IS NULL`; (d) for each: generate short_id (retry on `UniqueViolation`), copy storage object using `data = await storage.get(old_key); await storage.put(new_key, data, image.mime_type)` (the `StorageBackend` interface provides only `get`/`put`/`delete` — there is no `copy` method), verify the copy succeeded by calling `await storage.get(new_key)` and catching any exception, update the DB row (`short_id`, `storage_key`, `thumbnail_key`), then delete old keys with `await storage.delete(old_key)`; (e) skips images where `thumbnail_key IS NULL` for the thumbnail copy step; (f) wraps each image in a try/except so a single failure logs an error and continues to the next image; (g) prints `Migrated: N, Skipped: M, Failed: K` on completion. Entry point: `if __name__ == '__main__': asyncio.run(main())`. Run `make test-unit` and confirm T016 tests pass.
|
||||||
|
|
||||||
|
- [X] T018 Create Alembic migration `api/alembic/versions/004_short_id_not_null.py`: `op.alter_column('images', 'short_id', nullable=False)`. **Run this migration only after the migration script completes with 0 remaining NULL rows.** Downgrade sets nullable=True. Document this ordering requirement in the migration file's docstring.
|
||||||
|
|
||||||
|
**Checkpoint**: US3 complete. All existing images have short IDs, storage objects use new key pattern, `short_id` column is NOT NULL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [X] T019 Update `api/tests/integration/test_search.py`, `test_delete.py`, `test_serving.py`, `test_tags.py`, and `test_public_access.py`: wherever tests construct a URL with `f"/api/v1/images/{image.id}"` or `f"/api/v1/images/{uuid}"`, replace with `f"/api/v1/images/{image.short_id}"`. Ensure test fixtures (conftest.py) populate `short_id` on images created for testing. Run `make test-integration` and confirm all integration tests pass.
|
||||||
|
|
||||||
|
- [X] T020 Update `ui/src/app/detail/detail.component.spec.ts`: add `short_id: 'AbCd1234'` to `MOCK_IMAGE` and `MOCK_IMAGE_ABS` constants. Update any test assertions that check navigation targets to use `short_id`. Run `ng test --watch=false --include="src/app/detail/detail.component.spec.ts"` and confirm all tests pass.
|
||||||
|
|
||||||
|
- [X] T021 Run `ng lint` across all modified UI files and `ruff check api/app/ api/tests/ api/scripts/` across all modified API files; fix any issues. Confirm `ruff format --check api/` passes.
|
||||||
|
|
||||||
|
- [X] T022 Run `ng build --configuration production` and confirm build succeeds with no TypeScript errors. Run `make test-unit && make test-integration` and confirm all tests pass.
|
||||||
|
|
||||||
|
- [ ] T023 Manually verify all seven quickstart.md scenarios in the browser: (1) new upload navigates to `/i/{short_id}`; (2) deduplication returns same short_id; (3) library cards navigate to `/i/`; (4) tag and delete work via short_id; (5) pre-migration images accessible (after running script); (6) migration is idempotent; (7) "Copy URL" copies the CDN URL with short_id.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
- T001 before T002 (write failing tests before implementation)
|
||||||
|
- T002 before T003/T004/T005 (utility must exist before migration and model reference it)
|
||||||
|
- T003 before T004 (DB column must exist before model references it)
|
||||||
|
- T004 before T005 (model must have `short_id` field before repo uses it)
|
||||||
|
- T005 before T005a (conftest fixtures need the updated `create()` signature)
|
||||||
|
- T005a before T006 (integration test fixtures must have `short_id` before any integration tests run)
|
||||||
|
- T006 before T007 (write failing tests before implementation)
|
||||||
|
- T007 before T008 (helper and dict update before route param changes)
|
||||||
|
- T008 before T009/T010/T011 (API must accept short_id before frontend uses it)
|
||||||
|
- T009 before T010/T011/T011a/T012 (write failing tests before implementation)
|
||||||
|
- T010, T011, T011a, T012 can run in parallel (different files)
|
||||||
|
- T011 before T011a (interface must have `short_id` field before detail component uses it)
|
||||||
|
- T013 before T014 (write failing tests before upload changes)
|
||||||
|
- T014 before T015 (upload must produce short_id before frontend navigation uses it)
|
||||||
|
- T016 before T017 (write failing tests before script)
|
||||||
|
- T017 before T018 (script must run successfully before NOT NULL migration)
|
||||||
|
- T019–T023 after T018 (polish after all implementation complete)
|
||||||
|
|
||||||
|
### Execution Order Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: T001 → T002 (generate_short_id: tests → implementation)
|
||||||
|
Step 2: T003, T004 (parallel) (Alembic 003 + model update)
|
||||||
|
Step 3: T005 (repo: get_by_short_id + create update)
|
||||||
|
Step 3a: T005a (conftest: fixture images get short_id)
|
||||||
|
Step 4: T006 → T007 → T008 (API routes: tests → dict → route params)
|
||||||
|
Step 5: T009 (Angular failing tests)
|
||||||
|
Step 6: T010, T011, T011a, T012 (parallel) (route, interface, detail caller fix, library navigation)
|
||||||
|
Step 7: T013 → T014 → T015 (upload: tests → handler → upload navigation)
|
||||||
|
Step 8: T016 → T017 → T018 (migration: tests → script → NOT NULL migration)
|
||||||
|
Step 9: T019, T020 (parallel) (test updates)
|
||||||
|
Step 10: T021, T022, T023 (lint, build, manual verification)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP (US1 + US2 — full feature with new uploads)
|
||||||
|
|
||||||
|
1. T001–T005a — foundational infrastructure + conftest fixtures
|
||||||
|
2. T006–T012 (including T011a) — API routes accept short_id + frontend uses `/i/`
|
||||||
|
3. T013–T015 — new uploads generate short_id
|
||||||
|
4. **STOP and VALIDATE**: upload a new image, confirm `/i/{short_id}` URL, confirm browsing works
|
||||||
|
5. T016–T018 — migrate existing images
|
||||||
|
6. T019–T023 — polish
|
||||||
|
|
||||||
|
### Note on Priority vs Implementation Order
|
||||||
|
|
||||||
|
US1 (P1) and US2 (P2) are implemented together before US3 (P3). The foundational phase is the true prerequisite for all three. US1 and US2 are tightly coupled in practice (you need uploads to produce short IDs before routing can be tested end-to-end), so they are sequenced rather than strictly priority-ordered.
|
||||||
0
specs/018-pagination-controls/SHIPPED
Normal file
0
specs/018-pagination-controls/SHIPPED
Normal file
30
specs/018-pagination-controls/checklists/requirements.md
Normal file
30
specs/018-pagination-controls/checklists/requirements.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Specification Quality Checklist: Pagination Controls Redesign
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-05-10
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [X] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [X] Focused on user value and business needs
|
||||||
|
- [X] Written for non-technical stakeholders
|
||||||
|
- [X] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [X] Requirements are testable and unambiguous
|
||||||
|
- [X] Success criteria are measurable
|
||||||
|
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [X] All acceptance scenarios are defined
|
||||||
|
- [X] Edge cases are identified
|
||||||
|
- [X] Scope is clearly bounded
|
||||||
|
- [X] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [X] All functional requirements have clear acceptance criteria
|
||||||
|
- [X] User scenarios cover primary flows
|
||||||
|
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [X] No implementation details leak into specification
|
||||||
102
specs/018-pagination-controls/plan.md
Normal file
102
specs/018-pagination-controls/plan.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Implementation Plan: Pagination Controls Redesign
|
||||||
|
|
||||||
|
**Branch**: `018-pagination-controls` | **Date**: 2026-05-10 | **Spec**: [spec.md](spec.md)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the existing "← Previous / Page X of Y / Next →" pagination bar in `LibraryComponent` with six controls: first-page («), previous-page (‹), up to four numbered page buttons, next-page (›), and last-page (»). All logic stays in the existing component — no new component is introduced (§2.6: no speculative abstraction, only one paginated view exists).
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript (strict mode)
|
||||||
|
**Primary Dependencies**: Angular (latest stable), Karma + Jasmine
|
||||||
|
**Storage**: N/A — no data layer changes
|
||||||
|
**Testing**: Angular TestBed unit tests (component spec)
|
||||||
|
**Target Platform**: Browser SPA
|
||||||
|
**Project Type**: Web application — UI only
|
||||||
|
**Performance Goals**: No measurable regression in render or navigation time
|
||||||
|
**Constraints**: ESLint + Prettier must pass (§7.3); all existing tests must continue to pass (§5.4)
|
||||||
|
**Scale/Scope**: Single component change; one paginated view in the app
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| §2.6 No speculative abstraction | ✅ PASS | Pagination logic stays inline in LibraryComponent; no new component introduced |
|
||||||
|
| §5.1 Tests alongside implementation | ✅ PASS | Spec tests for window algorithm, disabled states, and navigation covered in tasks |
|
||||||
|
| §5.2 Test pyramid | ✅ PASS | Unit tests via TestBed; no integration or E2E tests required for a template change |
|
||||||
|
| §5.4 Suite must pass before done | ✅ PASS | Gate enforced per task |
|
||||||
|
| §7.3 Lint/format enforced | ✅ PASS | ESLint + Prettier gate on all tasks |
|
||||||
|
| §8 Scope boundaries | ✅ PASS | No out-of-scope work touched |
|
||||||
|
|
||||||
|
No violations. No Complexity Tracking table needed.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/018-pagination-controls/
|
||||||
|
├── plan.md ← this file
|
||||||
|
├── research.md
|
||||||
|
└── tasks.md (generated by /speckit-tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (changed files only)
|
||||||
|
|
||||||
|
```text
|
||||||
|
ui/src/app/library/
|
||||||
|
├── library.component.ts ← template, styles, class (page window getter + goToPage/firstPage/lastPage methods)
|
||||||
|
└── library.component.spec.ts ← new tests for window algorithm, disabled states, button navigation
|
||||||
|
```
|
||||||
|
|
||||||
|
No new files. No API changes. No data model changes.
|
||||||
|
|
||||||
|
## Page Window Algorithm
|
||||||
|
|
||||||
|
Given `currentPage` (1-based) and `totalPages`, compute the array of up to four page numbers to display:
|
||||||
|
|
||||||
|
```
|
||||||
|
start = max(1, currentPage - 1)
|
||||||
|
end = min(totalPages, start + 3)
|
||||||
|
start = max(1, end - 3) ← re-anchor if near the end
|
||||||
|
pages = [start .. end]
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Page 1 of 20 → [1, 2, 3, 4]
|
||||||
|
- Page 7 of 20 → [6, 7, 8, 9]
|
||||||
|
- Page 19 of 20 → [17, 18, 19, 20]
|
||||||
|
- Page 2 of 3 → [1, 2, 3]
|
||||||
|
|
||||||
|
## New Controls Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
« ‹ [1] [2] [3] [4] › »
|
||||||
|
```
|
||||||
|
|
||||||
|
- `«` disabled when `currentPage === 1`
|
||||||
|
- `‹` disabled when `currentPage === 1`
|
||||||
|
- Active page button has distinct active style
|
||||||
|
- `›` disabled when `currentPage === totalPages`
|
||||||
|
- `»` disabled when `currentPage === totalPages`
|
||||||
|
- Entire bar hidden when `totalPages <= 1` (existing behaviour retained)
|
||||||
|
|
||||||
|
## Methods to Add/Change
|
||||||
|
|
||||||
|
| Method | Change |
|
||||||
|
|--------|--------|
|
||||||
|
| `get pageWindow(): number[]` | New getter — returns array of up to 4 page numbers |
|
||||||
|
| `goToPage(page: number)` | New — navigates to arbitrary page number |
|
||||||
|
| `firstPage()` | New — navigates to page 1 |
|
||||||
|
| `lastPage()` | New — navigates to last page |
|
||||||
|
| `nextPage()` | Existing — no change needed |
|
||||||
|
| `prevPage()` | Existing — no change needed |
|
||||||
|
|
||||||
|
## Research
|
||||||
|
|
||||||
|
No unknowns. Tech stack is fixed (Angular/TypeScript). The windowing algorithm is a standard sliding-window with boundary clamping. No external research required.
|
||||||
|
|
||||||
|
**Decision**: Keep all logic in `LibraryComponent` (no child component).
|
||||||
|
**Rationale**: §2.6 prohibits speculative abstraction; only one paginated view exists in the app. Extracting a `PaginationComponent` would be justified only when a second use case appears.
|
||||||
|
**Alternatives considered**: Standalone `PaginationComponent` — rejected; no second consumer.
|
||||||
92
specs/018-pagination-controls/spec.md
Normal file
92
specs/018-pagination-controls/spec.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Feature Specification: Pagination Controls Redesign
|
||||||
|
|
||||||
|
**Feature Branch**: `018-pagination-controls`
|
||||||
|
**Created**: 2026-05-10
|
||||||
|
**Status**: Draft
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Navigate by Page Number (Priority: P1)
|
||||||
|
|
||||||
|
A user browsing the image library wants to jump directly to a specific page by clicking a numbered button rather than stepping through pages one at a time.
|
||||||
|
|
||||||
|
**Why this priority**: Direct page navigation is the core value of this feature — without numbered buttons the redesign delivers nothing new.
|
||||||
|
|
||||||
|
**Independent Test**: Load the library with enough images to produce multiple pages, confirm four numbered page buttons are visible, click one, and verify the correct page of images loads.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the library has more than one page of images, **When** the user views the pagination bar, **Then** up to four page number buttons are visible.
|
||||||
|
2. **Given** four page buttons are shown, **When** the user clicks a page number button, **Then** the library displays the images for that page and the clicked button appears in an active/selected state.
|
||||||
|
3. **Given** the total number of pages is four or fewer, **When** the user views the pagination bar, **Then** all pages are shown as numbered buttons with none hidden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Step Forward and Backward (Priority: P2)
|
||||||
|
|
||||||
|
A user wants to move one page at a time using previous and next controls without having to locate a specific page number.
|
||||||
|
|
||||||
|
**Why this priority**: Sequential navigation is a common browsing pattern and complements numbered buttons.
|
||||||
|
|
||||||
|
**Independent Test**: With multiple pages available, click the next chevron (›) and confirm the library advances one page; click the previous chevron (‹) and confirm it retreats one page.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user is not on the last page, **When** they click the next chevron (›), **Then** the library advances by one page.
|
||||||
|
2. **Given** the user is not on the first page, **When** they click the previous chevron (‹), **Then** the library retreats by one page.
|
||||||
|
3. **Given** the user is on the first page, **When** they view the pagination bar, **Then** the previous chevron (‹) is visually disabled and non-interactive.
|
||||||
|
4. **Given** the user is on the last page, **When** they view the pagination bar, **Then** the next chevron (›) is visually disabled and non-interactive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Jump to First and Last Page (Priority: P3)
|
||||||
|
|
||||||
|
A user wants to jump directly to the first or last page of the library without stepping through intermediate pages.
|
||||||
|
|
||||||
|
**Why this priority**: First/last navigation is a convenience for large libraries; useful but not essential.
|
||||||
|
|
||||||
|
**Independent Test**: Navigate to any middle page, click the last-page double chevron (»), and confirm the final page loads; click the first-page double chevron («) and confirm page one loads.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user is not on the first page, **When** they click the first-page double chevron («), **Then** the library jumps to page one.
|
||||||
|
2. **Given** the user is not on the last page, **When** they click the last-page double chevron (»), **Then** the library jumps to the final page.
|
||||||
|
3. **Given** the user is on the first page, **When** they view the pagination bar, **Then** the first-page double chevron («) is visually disabled and non-interactive.
|
||||||
|
4. **Given** the user is on the last page, **When** they view the pagination bar, **Then** the last-page double chevron (») is visually disabled and non-interactive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when there is only one page of images? The entire pagination bar is hidden.
|
||||||
|
- What happens when the current page is in the middle of a large range (e.g. page 7 of 20)? The four visible page buttons centre around the current page where possible.
|
||||||
|
- What happens when the current page is near the start or end of the total range? The window of four buttons anchors to the start or end rather than going out of range.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The pagination bar MUST display up to four numbered page buttons at a time.
|
||||||
|
- **FR-002**: The currently active page button MUST be visually distinguished from inactive page buttons.
|
||||||
|
- **FR-003**: The pagination bar MUST include a previous-page button (‹) and a next-page button (›).
|
||||||
|
- **FR-004**: The pagination bar MUST include a first-page button («) and a last-page button (»).
|
||||||
|
- **FR-005**: The previous (‹) and first-page («) controls MUST be disabled when the user is on page one.
|
||||||
|
- **FR-006**: The next (›) and last-page (») controls MUST be disabled when the user is on the final page.
|
||||||
|
- **FR-007**: The visible window of four page buttons MUST shift to keep the current page always in view.
|
||||||
|
- **FR-008**: The pagination bar MUST be hidden when the total number of pages is one or fewer.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: All six controls («, ‹, up to four page numbers, ›, ») are visible and correctly labelled on any page with more than four total pages.
|
||||||
|
- **SC-002**: Disabled controls are visually distinct and cannot be activated by the user.
|
||||||
|
- **SC-003**: The active page button always reflects the currently displayed page without requiring a page reload.
|
||||||
|
- **SC-004**: Navigating between pages does not introduce any additional loading delay beyond what the existing image fetch already takes.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The existing library already supports offset-based pagination; this feature changes only the navigation controls, not the underlying data fetching.
|
||||||
|
- The pagination bar is hidden when there is only one page, consistent with common library UX conventions.
|
||||||
|
- The four-button window shifts so the current page is always visible; no ellipsis or overflow indicator is required.
|
||||||
|
- Mobile layout is in scope; all controls must remain usable on small screens.
|
||||||
125
specs/018-pagination-controls/tasks.md
Normal file
125
specs/018-pagination-controls/tasks.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Tasks: Pagination Controls Redesign
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/018-pagination-controls/`
|
||||||
|
**Branch**: `018-pagination-controls`
|
||||||
|
|
||||||
|
**Scope**: Two files change — `library.component.ts` (template, styles, class) and `library.component.spec.ts` (tests). No API, no data model, no new files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
**Purpose**: Baseline verification before touching the component.
|
||||||
|
|
||||||
|
- [X] T001 Confirm existing library component tests pass by running `ng test --include=**/library.component.spec.ts --watch=false` in ui/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational
|
||||||
|
|
||||||
|
**Purpose**: No blocking infrastructure work required — all three user stories build directly on the existing `LibraryComponent`. Skipped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Page Number Navigation (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Replace the "Page X of Y" text with up to four clickable numbered page buttons. User can jump directly to any visible page.
|
||||||
|
|
||||||
|
**Independent Test**: With more than four pages of images, four numbered buttons appear; clicking a button loads that page and the button shows as active.
|
||||||
|
|
||||||
|
### Tests for User Story 1 (REQUIRED per §5.1)
|
||||||
|
|
||||||
|
- [X] T002 [US1] Write failing tests for `pageWindow` getter covering: first page (→ [1,2,3,4]), last page (→ last 4), middle page (current in window), total pages < 4 (all shown) in ui/src/app/library/library.component.spec.ts
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T003 [US1] Add `get pageWindow(): number[]` getter to `LibraryComponent` using the sliding-window algorithm from plan.md in ui/src/app/library/library.component.ts
|
||||||
|
- [X] T004 [US1] Add `goToPage(page: number)` method to `LibraryComponent` (navigate via router queryParam, call `load()`) in ui/src/app/library/library.component.ts
|
||||||
|
- [X] T005 [US1] Replace the `<span class="page-indicator">Page {{ currentPage }} of {{ totalPages }}</span>` with `*ngFor` numbered page buttons; add `.page-btn` and `.page-btn.active` styles; verify T002 tests pass in ui/src/app/library/library.component.ts
|
||||||
|
|
||||||
|
**Checkpoint**: Four numbered page buttons visible; clicking one loads the correct page; active button is highlighted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Previous/Next Chevrons (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Replace text "← Previous" / "Next →" buttons with ‹ › chevrons that are always rendered but visually disabled and non-interactive when at the first or last page.
|
||||||
|
|
||||||
|
**Independent Test**: On page 1 ‹ is disabled; on last page › is disabled; clicking either on a valid page advances or retreats by one.
|
||||||
|
|
||||||
|
### Tests for User Story 2 (REQUIRED per §5.1)
|
||||||
|
|
||||||
|
- [X] T006 [US2] Write failing tests for ‹ › disabled attribute and non-interactivity: disabled on page 1, disabled on last page, enabled otherwise in ui/src/app/library/library.component.spec.ts
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T007 [US2] Replace the `*ngIf`-gated ← Previous / Next → buttons with always-rendered `<button [disabled]="currentPage === 1">‹</button>` and `<button [disabled]="currentPage === totalPages">›</button>`; add `.pag-btn:disabled` style; verify T006 tests pass in ui/src/app/library/library.component.ts
|
||||||
|
|
||||||
|
**Checkpoint**: ‹ and › always visible; disabled at bounds; single-page step works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — First/Last Jump Buttons (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Add « and » buttons that jump directly to page 1 and the last page, disabled when already there.
|
||||||
|
|
||||||
|
**Independent Test**: From any middle page, « jumps to page 1 and » jumps to the last page; both are disabled when already at the respective bound.
|
||||||
|
|
||||||
|
### Tests for User Story 3 (REQUIRED per §5.1)
|
||||||
|
|
||||||
|
- [X] T008 [US3] Write failing tests for `firstPage()` and `lastPage()` methods and disabled states of « » at page boundaries in ui/src/app/library/library.component.spec.ts
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T009 [US3] Add `firstPage()` and `lastPage()` methods to `LibraryComponent`; add `<button [disabled]="currentPage === 1">«</button>` and `<button [disabled]="currentPage === totalPages">»</button>` to each end of the pagination bar; verify T008 tests pass in ui/src/app/library/library.component.ts
|
||||||
|
|
||||||
|
**Checkpoint**: Full bar renders as `« ‹ [1][2][3][4] › »`; all disabled states correct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [X] T010 Apply final styles: consistent button sizing, gap spacing, and mobile-friendly layout (flex-wrap or min-width as needed) for the full pagination bar in ui/src/app/library/library.component.ts
|
||||||
|
- [X] T011 Run ESLint and Prettier on ui/src/app/library/ and resolve any issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
- **T001**: Run first — baseline gate
|
||||||
|
- **T002 → T003 → T004 → T005**: Sequential (tests before implementation; each method before its template usage)
|
||||||
|
- **T006 → T007**: Sequential (tests before implementation)
|
||||||
|
- **T008 → T009**: Sequential (tests before implementation)
|
||||||
|
- **T010, T011**: After all story phases complete; can run in either order
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: Independent — starts after T001
|
||||||
|
- **US2 (P2)**: Starts after US1 is complete (shares same template section)
|
||||||
|
- **US3 (P3)**: Starts after US2 is complete (adds to the same template section)
|
||||||
|
|
||||||
|
All three stories touch the same two files, so parallel execution is not applicable here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP (User Story 1 only)
|
||||||
|
|
||||||
|
1. T001: Baseline check
|
||||||
|
2. T002–T005: Numbered buttons + goToPage
|
||||||
|
3. **Validate**: Four page buttons work, active state correct
|
||||||
|
4. Defer US2 and US3 if shipping early
|
||||||
|
|
||||||
|
### Full Delivery
|
||||||
|
|
||||||
|
1. T001 baseline → US1 (T002–T005) → US2 (T006–T007) → US3 (T008–T009) → Polish (T010–T011)
|
||||||
|
2. Each story checkpoint validates independence before moving on
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `pageWindow` algorithm: `start = max(1, currentPage-1); end = min(totalPages, start+3); start = max(1, end-3); pages = [start..end]`
|
||||||
|
- No `[P]` markers — all tasks share the same two files and must run sequentially
|
||||||
|
- Entire pagination bar hidden when `totalPages <= 1` (existing behaviour; do not regress)
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular-devkit/build-angular:karma",
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
"options": {
|
"options": {
|
||||||
|
"karmaConfig": "karma.conf.js",
|
||||||
"polyfills": ["zone.js", "zone.js/testing"],
|
"polyfills": ["zone.js", "zone.js/testing"],
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"assets": [
|
"assets": [
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ module.exports = function (config) {
|
|||||||
jasmineHtmlReporter: { suppressAll: true },
|
jasmineHtmlReporter: { suppressAll: true },
|
||||||
coverageReporter: { dir: require('path').join(__dirname, './coverage/reactbin-ui'), subdir: '.', reporters: [{ type: 'html' }, { type: 'text-summary' }] },
|
coverageReporter: { dir: require('path').join(__dirname, './coverage/reactbin-ui'), subdir: '.', reporters: [{ type: 'html' }, { type: 'text-summary' }] },
|
||||||
reporters: ['progress', 'kjhtml'],
|
reporters: ['progress', 'kjhtml'],
|
||||||
browsers: ['Chrome'],
|
customLaunchers: {
|
||||||
|
FirefoxHeadless: {
|
||||||
|
base: 'Firefox',
|
||||||
|
flags: ['--headless'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
browsers: ['FirefoxHeadless'],
|
||||||
restartOnFileChange: true,
|
restartOnFileChange: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const routes: Routes = [
|
|||||||
import('./tags/tags.component').then((m) => m.TagsComponent),
|
import('./tags/tags.component').then((m) => m.TagsComponent),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'images/:id',
|
path: 'i/:id',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./detail/detail.component').then((m) => m.DetailComponent),
|
import('./detail/detail.component').then((m) => m.DetailComponent),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import { ToastService } from '../services/toast.service';
|
|||||||
import { routes } from '../app.routes';
|
import { routes } from '../app.routes';
|
||||||
|
|
||||||
const MOCK_IMAGE = {
|
const MOCK_IMAGE = {
|
||||||
id: 'img-1', hash: 'abc', filename: 'test.jpg', mime_type: 'image/jpeg',
|
id: 'img-1', short_id: 'AbCd1234', hash: 'abc', filename: 'test.jpg', mime_type: 'image/jpeg',
|
||||||
size_bytes: 100, width: 10, height: 10, storage_key: 'abc',
|
size_bytes: 100, width: 10, height: 10, storage_key: 'AbCd1234',
|
||||||
thumbnail_key: null, file_url: '/api/v1/images/img-1/file', thumbnail_url: null,
|
thumbnail_key: null, file_url: '/api/v1/i/AbCd1234/file', thumbnail_url: null,
|
||||||
created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'],
|
created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'],
|
||||||
};
|
};
|
||||||
const MOCK_IMAGE_ABS = { ...MOCK_IMAGE, file_url: 'https://cdn.example.com/img-1.jpg' };
|
const MOCK_IMAGE_ABS = { ...MOCK_IMAGE, file_url: 'https://cdn.example.com/img-1.jpg' };
|
||||||
@@ -39,14 +39,14 @@ describe('DetailComponent', () => {
|
|||||||
const { component, imgSvc } = setup();
|
const { component, imgSvc } = setup();
|
||||||
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
|
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
|
||||||
component.removeTag('cat');
|
component.removeTag('cat');
|
||||||
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['funny']);
|
expect(imgSvc.updateTags).toHaveBeenCalledWith('AbCd1234', ['funny']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call PATCH with new tag included on addTag', () => {
|
it('should call PATCH with new tag included on addTag', () => {
|
||||||
const { component, imgSvc } = setup();
|
const { component, imgSvc } = setup();
|
||||||
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['cat', 'funny', 'new'] }));
|
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['cat', 'funny', 'new'] }));
|
||||||
component.addTag('new');
|
component.addTag('new');
|
||||||
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['cat', 'funny', 'new']);
|
expect(imgSvc.updateTags).toHaveBeenCalledWith('AbCd1234', ['cat', 'funny', 'new']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call DELETE and navigate to library on confirm delete', () => {
|
it('should call DELETE and navigate to library on confirm delete', () => {
|
||||||
@@ -55,7 +55,7 @@ describe('DetailComponent', () => {
|
|||||||
spyOn(router, 'navigate');
|
spyOn(router, 'navigate');
|
||||||
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
|
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
|
||||||
component.confirmDelete();
|
component.confirmDelete();
|
||||||
expect(imgSvc.delete).toHaveBeenCalledWith('img-1');
|
expect(imgSvc.delete).toHaveBeenCalledWith('AbCd1234');
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,17 +113,15 @@ describe('DetailComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('not-found card shown when image is null, loading is false, error is false', () => {
|
it('not-found card shown when image is null, loading is false, error is false', () => {
|
||||||
const { fixture, component } = setup('img-1', of(MOCK_IMAGE));
|
// Service returns null → fetchImage sets image=null, loading=false, markForCheck()
|
||||||
component.image = null;
|
const { fixture } = setup('img-1', of(null as unknown as typeof MOCK_IMAGE));
|
||||||
component.loading = false;
|
|
||||||
component.error = false;
|
|
||||||
fixture.detectChanges();
|
|
||||||
expect((fixture.nativeElement as HTMLElement).querySelector('.not-found-card')).not.toBeNull();
|
expect((fixture.nativeElement as HTMLElement).querySelector('.not-found-card')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tag error element uses danger styling class', () => {
|
it('tag error element uses danger styling class', () => {
|
||||||
const { fixture, component } = setup();
|
const { fixture, component, imgSvc } = setup();
|
||||||
component.tagError = 'Invalid tag: special characters not allowed';
|
spyOn(imgSvc, 'updateTags').and.returnValue(throwError(() => ({ error: { detail: 'Invalid tag' } })));
|
||||||
|
component.addTag('bad#tag');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const errEl = (fixture.nativeElement as HTMLElement).querySelector('.tag-error');
|
const errEl = (fixture.nativeElement as HTMLElement).querySelector('.tag-error');
|
||||||
expect(errEl).not.toBeNull();
|
expect(errEl).not.toBeNull();
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ export class DetailComponent implements OnInit {
|
|||||||
removeTag(tag: string): void {
|
removeTag(tag: string): void {
|
||||||
if (!this.image) return;
|
if (!this.image) return;
|
||||||
const updated = this.image.tags.filter((t) => t !== tag);
|
const updated = this.image.tags.filter((t) => t !== tag);
|
||||||
this.imageService.updateTags(this.image.id, updated).subscribe({
|
this.imageService.updateTags(this.image.short_id, updated).subscribe({
|
||||||
next: (img) => { this.image = img; this.tagError = ''; this.cdr.markForCheck(); },
|
next: (img) => { this.image = img; this.tagError = ''; this.cdr.markForCheck(); },
|
||||||
error: (err) => { this.tagError = err?.error?.detail ?? 'Failed to remove tag'; this.cdr.markForCheck(); },
|
error: (err) => { this.tagError = err?.error?.detail ?? 'Failed to remove tag'; this.cdr.markForCheck(); },
|
||||||
});
|
});
|
||||||
@@ -221,7 +221,7 @@ export class DetailComponent implements OnInit {
|
|||||||
if (!this.image || !tag.trim()) return;
|
if (!this.image || !tag.trim()) return;
|
||||||
const normalised = tag.trim().toLowerCase();
|
const normalised = tag.trim().toLowerCase();
|
||||||
const updated = [...this.image.tags, normalised];
|
const updated = [...this.image.tags, normalised];
|
||||||
this.imageService.updateTags(this.image.id, updated).subscribe({
|
this.imageService.updateTags(this.image.short_id, updated).subscribe({
|
||||||
next: (img) => { this.image = img; this.newTagInput = ''; this.tagError = ''; this.cdr.markForCheck(); },
|
next: (img) => { this.image = img; this.newTagInput = ''; this.tagError = ''; this.cdr.markForCheck(); },
|
||||||
error: (err) => { this.tagError = err?.error?.detail ?? 'Invalid tag'; this.cdr.markForCheck(); },
|
error: (err) => { this.tagError = err?.error?.detail ?? 'Invalid tag'; this.cdr.markForCheck(); },
|
||||||
});
|
});
|
||||||
@@ -232,7 +232,7 @@ export class DetailComponent implements OnInit {
|
|||||||
|
|
||||||
confirmDelete(): void {
|
confirmDelete(): void {
|
||||||
if (!this.image) return;
|
if (!this.image) return;
|
||||||
this.imageService.delete(this.image.id).subscribe({
|
this.imageService.delete(this.image.short_id).subscribe({
|
||||||
next: () => this.router.navigate(['/']),
|
next: () => this.router.navigate(['/']),
|
||||||
error: () => { this.showDeleteDialog = false; this.cdr.markForCheck(); },
|
error: () => { this.showDeleteDialog = false; this.cdr.markForCheck(); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ function makeActivatedRoute(queryParams: Record<string, string> = {}) {
|
|||||||
|
|
||||||
const EMPTY_PAGE = { items: [], total: 0, limit: 24, offset: 0 };
|
const EMPTY_PAGE = { items: [], total: 0, limit: 24, offset: 0 };
|
||||||
const ONE_IMAGE = {
|
const ONE_IMAGE = {
|
||||||
items: [{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, file_url: '/api/v1/images/1/file', thumbnail_url: null, created_at: '' }],
|
items: [{ id: '1', short_id: 'ShrtImg1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: 'ShrtImg1', thumbnail_key: null, file_url: '/api/v1/i/ShrtImg1/file', thumbnail_url: null, created_at: '' }],
|
||||||
total: 1, limit: 24, offset: 0,
|
total: 1, limit: 24, offset: 0,
|
||||||
};
|
};
|
||||||
const MULTI_PAGE = {
|
const MULTI_PAGE = {
|
||||||
items: Array(24).fill(null).map((_, i) => ({
|
items: Array(24).fill(null).map((_, i) => ({
|
||||||
id: String(i + 1), filename: `img${i + 1}.jpg`, tags: [], hash: '',
|
id: String(i + 1), short_id: `Shrt${String(i + 1).padStart(4, '0')}`, filename: `img${i + 1}.jpg`, tags: [], hash: '',
|
||||||
mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1,
|
mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1,
|
||||||
storage_key: '', thumbnail_key: null,
|
storage_key: `Shrt${String(i + 1).padStart(4, '0')}`, thumbnail_key: null,
|
||||||
file_url: `/api/v1/images/${i + 1}/file`, thumbnail_url: null, created_at: '',
|
file_url: `/api/v1/i/Shrt${String(i + 1).padStart(4, '0')}/file`, thumbnail_url: null, created_at: '',
|
||||||
})),
|
})),
|
||||||
total: 48, limit: 24, offset: 0,
|
total: 48, limit: 24, offset: 0,
|
||||||
};
|
};
|
||||||
@@ -155,15 +155,72 @@ describe('LibraryComponent', () => {
|
|||||||
expect(link).not.toBeNull();
|
expect(link).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Pagination: US1 ----
|
// ---- Pagination: page window (T002) ----
|
||||||
|
|
||||||
it('page indicator shows "Page 1 of 2" when totalPages > 1', () => {
|
it('pageWindow returns [1,2,3,4] on page 1 of 20', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
fixture.componentInstance.currentPage = 1;
|
||||||
|
fixture.componentInstance.totalPages = 20;
|
||||||
|
expect(fixture.componentInstance.pageWindow).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pageWindow returns [17,18,19,20] on page 20 of 20', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
fixture.componentInstance.currentPage = 20;
|
||||||
|
fixture.componentInstance.totalPages = 20;
|
||||||
|
expect(fixture.componentInstance.pageWindow).toEqual([17, 18, 19, 20]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pageWindow returns [6,7,8,9] on page 7 of 20', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
fixture.componentInstance.currentPage = 7;
|
||||||
|
fixture.componentInstance.totalPages = 20;
|
||||||
|
expect(fixture.componentInstance.pageWindow).toEqual([6, 7, 8, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pageWindow returns all pages when totalPages < 4', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
fixture.componentInstance.currentPage = 2;
|
||||||
|
fixture.componentInstance.totalPages = 3;
|
||||||
|
expect(fixture.componentInstance.pageWindow).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pageWindow returns [1] when totalPages is 1', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
fixture.componentInstance.currentPage = 1;
|
||||||
|
fixture.componentInstance.totalPages = 1;
|
||||||
|
expect(fixture.componentInstance.pageWindow).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numbered page buttons are rendered (T002)', () => {
|
||||||
const fixture = TestBed.createComponent(LibraryComponent);
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
const imgSvc = TestBed.inject(ImageService);
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const indicator = (fixture.nativeElement as HTMLElement).querySelector('.page-indicator');
|
const pageBtns = (fixture.nativeElement as HTMLElement).querySelectorAll('.page-btn');
|
||||||
expect(indicator?.textContent).toContain('Page 1 of 2');
|
expect(pageBtns.length).toBe(2); // 2 total pages
|
||||||
|
expect(pageBtns[0].textContent?.trim()).toBe('1');
|
||||||
|
expect(pageBtns[1].textContent?.trim()).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('active page button has .active class', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const activeBtn = (fixture.nativeElement as HTMLElement).querySelector('.page-btn.active');
|
||||||
|
expect(activeBtn).not.toBeNull();
|
||||||
|
expect(activeBtn?.textContent?.trim()).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goToPage() calls imageService.list with correct offset', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
listSpy.calls.reset();
|
||||||
|
fixture.componentInstance.goToPage(2);
|
||||||
|
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 24);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('total count renders with correct number', () => {
|
it('total count renders with correct number', () => {
|
||||||
@@ -175,32 +232,6 @@ describe('LibraryComponent', () => {
|
|||||||
expect(el?.textContent).toContain('48');
|
expect(el?.textContent).toContain('48');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('"Next" button present when not on last page', () => {
|
|
||||||
const fixture = TestBed.createComponent(LibraryComponent);
|
|
||||||
const imgSvc = TestBed.inject(ImageService);
|
|
||||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
|
||||||
fixture.detectChanges();
|
|
||||||
expect((fixture.nativeElement as HTMLElement).querySelector('.next-btn')).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('"Previous" button absent on first page', () => {
|
|
||||||
const fixture = TestBed.createComponent(LibraryComponent);
|
|
||||||
const imgSvc = TestBed.inject(ImageService);
|
|
||||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
|
||||||
fixture.detectChanges();
|
|
||||||
expect((fixture.nativeElement as HTMLElement).querySelector('.prev-btn')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('"Previous" present and "Next" absent on last page', () => {
|
|
||||||
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
|
||||||
const fixture = TestBed.createComponent(LibraryComponent);
|
|
||||||
const imgSvc = TestBed.inject(ImageService);
|
|
||||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
|
||||||
fixture.detectChanges();
|
|
||||||
expect((fixture.nativeElement as HTMLElement).querySelector('.prev-btn')).not.toBeNull();
|
|
||||||
expect((fixture.nativeElement as HTMLElement).querySelector('.next-btn')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no pagination controls when all images fit on one page', () => {
|
it('no pagination controls when all images fit on one page', () => {
|
||||||
const fixture = TestBed.createComponent(LibraryComponent);
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
const imgSvc = TestBed.inject(ImageService);
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
@@ -242,7 +273,58 @@ describe('LibraryComponent', () => {
|
|||||||
expect(listSpy).toHaveBeenCalledWith(['cat'], jasmine.any(Number), 0);
|
expect(listSpy).toHaveBeenCalledWith(['cat'], jasmine.any(Number), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Pagination: US2 — URL state ----
|
// ---- Pagination: ‹ › disabled states (T006) ----
|
||||||
|
|
||||||
|
it('prev-btn (‹) is disabled on page 1', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const prevBtn = (fixture.nativeElement as HTMLElement).querySelector('.prev-btn') as HTMLButtonElement;
|
||||||
|
expect(prevBtn).not.toBeNull();
|
||||||
|
expect(prevBtn.disabled).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('next-btn (›) is disabled on last page', () => {
|
||||||
|
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const nextBtn = (fixture.nativeElement as HTMLElement).querySelector('.next-btn') as HTMLButtonElement;
|
||||||
|
expect(nextBtn).not.toBeNull();
|
||||||
|
expect(nextBtn.disabled).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prev-btn (‹) is enabled when not on page 1', () => {
|
||||||
|
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const prevBtn = (fixture.nativeElement as HTMLElement).querySelector('.prev-btn') as HTMLButtonElement;
|
||||||
|
expect(prevBtn.disabled).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('next-btn (›) is enabled when not on last page', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const nextBtn = (fixture.nativeElement as HTMLElement).querySelector('.next-btn') as HTMLButtonElement;
|
||||||
|
expect(nextBtn.disabled).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both prev and next buttons always rendered when totalPages > 1', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.prev-btn')).not.toBeNull();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.next-btn')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Pagination: URL state ----
|
||||||
|
|
||||||
it('reads ?page=2 from queryParamMap on init and calls list with offset=24', () => {
|
it('reads ?page=2 from queryParamMap on init and calls list with offset=24', () => {
|
||||||
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
||||||
@@ -260,7 +342,6 @@ describe('LibraryComponent', () => {
|
|||||||
const imgSvc = TestBed.inject(ImageService);
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
// After load, totalPages=2, currentPage should be clamped to 2 (not 9999), then router corrects URL
|
|
||||||
expect(fixture.componentInstance.currentPage).toBeLessThanOrEqual(fixture.componentInstance.totalPages);
|
expect(fixture.componentInstance.currentPage).toBeLessThanOrEqual(fixture.componentInstance.totalPages);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -292,4 +373,81 @@ describe('LibraryComponent', () => {
|
|||||||
queryParamsHandling: 'merge',
|
queryParamsHandling: 'merge',
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clicking an image card navigates to /i/:short_id', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(ONE_IMAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
spyOn(router, 'navigate');
|
||||||
|
const card = (fixture.nativeElement as HTMLElement).querySelector('.image-card') as HTMLElement;
|
||||||
|
card.click();
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/i', 'ShrtImg1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Pagination: « » first/last (T008) ----
|
||||||
|
|
||||||
|
it('firstPage() navigates to page 1', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.currentPage = 2;
|
||||||
|
fixture.componentInstance.totalPages = 2;
|
||||||
|
listSpy.calls.reset();
|
||||||
|
fixture.componentInstance.firstPage();
|
||||||
|
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 0);
|
||||||
|
expect(fixture.componentInstance.currentPage).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lastPage() navigates to last page', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
listSpy.calls.reset();
|
||||||
|
fixture.componentInstance.lastPage();
|
||||||
|
expect(fixture.componentInstance.currentPage).toBe(fixture.componentInstance.totalPages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('first-page button («) is disabled on page 1', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const firstBtn = (fixture.nativeElement as HTMLElement).querySelector('.first-btn') as HTMLButtonElement;
|
||||||
|
expect(firstBtn).not.toBeNull();
|
||||||
|
expect(firstBtn.disabled).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('last-page button (») is disabled on last page', () => {
|
||||||
|
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const lastBtn = (fixture.nativeElement as HTMLElement).querySelector('.last-btn') as HTMLButtonElement;
|
||||||
|
expect(lastBtn).not.toBeNull();
|
||||||
|
expect(lastBtn.disabled).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('first-page button («) is enabled when not on page 1', () => {
|
||||||
|
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const firstBtn = (fixture.nativeElement as HTMLElement).querySelector('.first-btn') as HTMLButtonElement;
|
||||||
|
expect(firstBtn.disabled).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('last-page button (») is enabled when not on last page', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const lastBtn = (fixture.nativeElement as HTMLElement).querySelector('.last-btn') as HTMLButtonElement;
|
||||||
|
expect(lastBtn.disabled).toBeFalse();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
|||||||
class="image-card"
|
class="image-card"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
(click)="router.navigate(['/images', img.id])"
|
(click)="router.navigate(['/i', img.short_id])"
|
||||||
(keydown.enter)="router.navigate(['/images', img.id])"
|
(keydown.enter)="router.navigate(['/i', img.short_id])"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
[src]="img.thumbnail_url ?? img.file_url"
|
[src]="img.thumbnail_url ?? img.file_url"
|
||||||
@@ -90,9 +90,17 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
|||||||
|
|
||||||
<!-- Pagination controls — only when more than one page -->
|
<!-- Pagination controls — only when more than one page -->
|
||||||
<div *ngIf="totalPages > 1 && !showSpinner && !error" class="pagination-bar">
|
<div *ngIf="totalPages > 1 && !showSpinner && !error" class="pagination-bar">
|
||||||
<button *ngIf="currentPage > 1" class="prev-btn" (click)="prevPage()">← Previous</button>
|
<button class="pag-btn first-btn" [disabled]="currentPage === 1" (click)="firstPage()" aria-label="First page">«</button>
|
||||||
<span class="page-indicator">Page {{ currentPage }} of {{ totalPages }}</span>
|
<button class="pag-btn prev-btn" [disabled]="currentPage === 1" (click)="prevPage()" aria-label="Previous page">‹</button>
|
||||||
<button *ngIf="currentPage < totalPages" class="next-btn" (click)="nextPage()">Next →</button>
|
<button
|
||||||
|
*ngFor="let p of pageWindow"
|
||||||
|
class="pag-btn page-btn"
|
||||||
|
[class.active]="p === currentPage"
|
||||||
|
(click)="goToPage(p)"
|
||||||
|
[attr.aria-current]="p === currentPage ? 'page' : null"
|
||||||
|
>{{ p }}</button>
|
||||||
|
<button class="pag-btn next-btn" [disabled]="currentPage === totalPages" (click)="nextPage()" aria-label="Next page">›</button>
|
||||||
|
<button class="pag-btn last-btn" [disabled]="currentPage === totalPages" (click)="lastPage()" aria-label="Last page">»</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
@@ -130,10 +138,11 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
|||||||
.retry-btn { padding: 8px 24px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
|
.retry-btn { padding: 8px 24px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
|
||||||
.retry-btn:hover { border-color: var(--border-focus); }
|
.retry-btn:hover { border-color: var(--border-focus); }
|
||||||
.total-count { text-align: center; color: var(--text-muted); font-size: 0.85rem; margin: 16px 0 8px; }
|
.total-count { text-align: center; color: var(--text-muted); font-size: 0.85rem; margin: 16px 0 8px; }
|
||||||
.pagination-bar { display: flex; justify-content: center; align-items: center; gap: 16px; margin: 16px 0 24px; }
|
.pagination-bar { display: flex; justify-content: center; align-items: center; gap: 6px; margin: 16px 0 24px; flex-wrap: wrap; }
|
||||||
.prev-btn, .next-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
|
.pag-btn { min-width: 36px; height: 36px; padding: 0 10px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; font-size: 0.95rem; transition: border-color var(--transition), background var(--transition); }
|
||||||
.prev-btn:hover, .next-btn:hover { border-color: var(--border-focus); }
|
.pag-btn:hover:not(:disabled) { border-color: var(--border-focus); }
|
||||||
.page-indicator { color: var(--text-muted); font-size: 0.9rem; }
|
.pag-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.page-btn.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||||
`],
|
`],
|
||||||
})
|
})
|
||||||
export class LibraryComponent implements OnInit {
|
export class LibraryComponent implements OnInit {
|
||||||
@@ -225,6 +234,37 @@ export class LibraryComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get pageWindow(): number[] {
|
||||||
|
let start = Math.max(1, this.currentPage - 1);
|
||||||
|
const end = Math.min(this.totalPages, start + 3);
|
||||||
|
start = Math.max(1, end - 3);
|
||||||
|
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPage(page: number): void {
|
||||||
|
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||||
|
this.currentPage = page;
|
||||||
|
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
firstPage(): void {
|
||||||
|
if (this.currentPage !== 1) {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.router.navigate([], { queryParams: { page: 1 }, queryParamsHandling: 'merge' });
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPage(): void {
|
||||||
|
if (this.currentPage !== this.totalPages) {
|
||||||
|
this.currentPage = this.totalPages;
|
||||||
|
this.router.navigate([], { queryParams: { page: this.totalPages }, queryParamsHandling: 'merge' });
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nextPage(): void {
|
nextPage(): void {
|
||||||
if (this.currentPage < this.totalPages) {
|
if (this.currentPage < this.totalPages) {
|
||||||
this.currentPage++;
|
this.currentPage++;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { of, throwError } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import { LoginComponent } from './login.component';
|
import { LoginComponent } from './login.component';
|
||||||
@@ -20,6 +20,7 @@ describe('LoginComponent', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: AuthService, useValue: authService },
|
{ provide: AuthService, useValue: authService },
|
||||||
{ provide: Router, useValue: router },
|
{ provide: Router, useValue: router },
|
||||||
|
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Observable } from 'rxjs';
|
|||||||
|
|
||||||
export interface ImageRecord {
|
export interface ImageRecord {
|
||||||
id: string;
|
id: string;
|
||||||
|
short_id: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
mime_type: string;
|
mime_type: string;
|
||||||
@@ -50,14 +51,14 @@ export class ImageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get(id: string): Observable<ImageRecord> {
|
get(id: string): Observable<ImageRecord> {
|
||||||
return this.http.get<ImageRecord>(`${this.base}/images/${id}`);
|
return this.http.get<ImageRecord>(`${this.base}/i/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTags(id: string, tags: string[]): Observable<ImageRecord> {
|
updateTags(id: string, tags: string[]): Observable<ImageRecord> {
|
||||||
return this.http.patch<ImageRecord>(`${this.base}/images/${id}/tags`, { tags });
|
return this.http.patch<ImageRecord>(`${this.base}/i/${id}/tags`, { tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(id: string): Observable<void> {
|
delete(id: string): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.base}/images/${id}`);
|
return this.http.delete<void>(`${this.base}/i/${id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ describe('UploadComponent', () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
const router = TestBed.inject(Router);
|
const router = TestBed.inject(Router);
|
||||||
spyOn(router, 'navigate');
|
spyOn(router, 'navigate');
|
||||||
await component.handleUploadResponse({ id: 'abc', duplicate: true } as Parameters<typeof component.handleUploadResponse>[0]);
|
await component.handleUploadResponse({ id: 'abc', short_id: 'AbCd1234', duplicate: true } as Parameters<typeof component.handleUploadResponse>[0]);
|
||||||
expect(component.toastMessage).toContain('library');
|
expect(component.toastMessage).toContain('library');
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['/images', 'abc']);
|
expect(router.navigate).toHaveBeenCalledWith(['/i', 'AbCd1234']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('on success response: shows success toast and navigates to detail', async () => {
|
it('on success response: shows success toast and navigates to detail', async () => {
|
||||||
@@ -47,9 +47,9 @@ describe('UploadComponent', () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
const router = TestBed.inject(Router);
|
const router = TestBed.inject(Router);
|
||||||
spyOn(router, 'navigate');
|
spyOn(router, 'navigate');
|
||||||
await component.handleUploadResponse({ id: 'xyz', duplicate: false } as Parameters<typeof component.handleUploadResponse>[0]);
|
await component.handleUploadResponse({ id: 'xyz', short_id: 'XyZ12345', duplicate: false } as Parameters<typeof component.handleUploadResponse>[0]);
|
||||||
expect(component.toastMessage).toBeTruthy();
|
expect(component.showSuccess).toBeTrue();
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['/images', 'xyz']);
|
expect(router.navigate).toHaveBeenCalledWith(['/i', 'XyZ12345']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('on error response: shows inline error, no navigation', async () => {
|
it('on error response: shows inline error, no navigation', async () => {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export class UploadComponent {
|
|||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
}, 4000);
|
}, 4000);
|
||||||
}
|
}
|
||||||
await this.router.navigate(['/images', res.id]);
|
await this.router.navigate(['/i', res.short_id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleUploadError(err: unknown): void {
|
handleUploadError(err: unknown): void {
|
||||||
@@ -192,6 +192,7 @@ export class UploadComponent {
|
|||||||
} else {
|
} else {
|
||||||
this.errorMessage = 'Upload failed. Please try again.';
|
this.errorMessage = 'Upload failed. Please try again.';
|
||||||
}
|
}
|
||||||
|
this.cdr.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
resetForm(): void {
|
resetForm(): void {
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
<meta property="og:title" content="Reactbin">
|
<meta property="og:title" content="Reactbin">
|
||||||
<meta property="og:description" content="Find your perfect reaction image.">
|
<meta property="og:description" content="Find your perfect reaction image.">
|
||||||
<meta property="og:url" content="https://reactbin.juggalol.com">
|
<meta property="og:url" content="https://reactbin.juggalol.com">
|
||||||
<meta property="og:image" content="https://cdn.reactbin.juggalol.com/0bdaf046f534a1c913629d7ce8069ce407898c19794527bf842524ff4c0073de">
|
<meta property="og:image" content="https://cdn.reactbin.juggalol.com/baYB6eiC">
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Reactbin">
|
<meta name="twitter:title" content="Reactbin">
|
||||||
<meta name="twitter:description" content="Find your perfect reaction image.">
|
<meta name="twitter:description" content="Find your perfect reaction image.">
|
||||||
<meta name="twitter:image" content="https://cdn.reactbin.juggalol.com/0bdaf046f534a1c913629d7ce8069ce407898c19794527bf842524ff4c0073de">
|
<meta name="twitter:image" content="https://cdn.reactbin.juggalol.com/baYB6eiC">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
|
||||||
|
|||||||
Reference in New Issue
Block a user