Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c09106961c | ||
|
|
0f4c6989a4 | ||
|
|
2cfcd02588 | ||
|
|
332d65255c | ||
|
|
da3f999c6f | ||
|
|
42f05bdd0f | ||
|
|
434bdafefd | ||
|
|
a248541e58 |
@@ -1,41 +0,0 @@
|
||||
# Publish the project's wiki pages to the git host's wiki.
|
||||
#
|
||||
# On a push to main that changes docs/wiki/, this workflow calls the reusable
|
||||
# workflow of TirSystem/github-action, which copies the Markdown pages of
|
||||
# docs/wiki/ to the wiki of this repository (framework/process/wiki.md says
|
||||
# what the folder holds: English and the PO language as a minimum).
|
||||
#
|
||||
# INSTALLING THIS FILE IS THE OPT-IN. There is no marker file. A project that
|
||||
# does not copy it publishes nothing.
|
||||
#
|
||||
# Copy it to .gitea/workflows/publish-wiki.yml. Then:
|
||||
# 1. Create the secret TOKEN_FOR_GITEA_WIKI in the organization of the
|
||||
# repository: a Gitea token with scope write:repository for a user that
|
||||
# can write this repository (see guides/credentials.md of github-action).
|
||||
# 2. Enable the wiki in the repository settings.
|
||||
# 3. Keep @v1, or pin the exact release tag together with tools-ref.
|
||||
# A runner must be registered for the repository (or its organization).
|
||||
#
|
||||
# prune: "true" deletes wiki pages that are not in docs/wiki/, so the wiki
|
||||
# always equals the folder. Leave it "false" to keep hand-written pages.
|
||||
|
||||
name: Publish wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- "docs/wiki/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
wiki:
|
||||
uses: TirSystem/git-workflows/.gitea/workflows/publish-wiki-pages.yml@v1
|
||||
with:
|
||||
wiki_dir: docs/wiki
|
||||
prune: "true"
|
||||
secrets:
|
||||
TOKEN_FOR_GITEA_WIKI: ${{ secrets.TOKEN_FOR_GITEA_WIKI }}
|
||||
@@ -14,7 +14,8 @@
|
||||
# Copy it to .gitea/workflows/sync-milestones.yml (Gitea Actions) or
|
||||
# .github/workflows/sync-milestones.yml (GitHub Actions). Then:
|
||||
# 1. Create a CI secret and put its name in the two `secrets.` lines below
|
||||
# (SYNC_TOKEN is a placeholder). Gitea does not accept secret names that
|
||||
# (TOKEN_FOR_GITEA_ISSUES is the name the
|
||||
# framework uses). Gitea does not accept secret names that
|
||||
# start with GITEA_ or GITHUB_.
|
||||
# 2. Give the token the smallest scope that works: read and write on issues
|
||||
# and milestones. If the framework submodule is a private repository, the
|
||||
@@ -53,21 +54,32 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out the project and its framework submodule
|
||||
- name: Check out the project
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ secrets.SYNC_TOKEN }}
|
||||
token: ${{ secrets.TOKEN_FOR_GITEA_ISSUES }}
|
||||
|
||||
# The framework submodule is registered with an SSH address, which the runner
|
||||
# cannot use. The address is rewritten to HTTPS for this step only, and the token
|
||||
# goes in through the environment, never on a command line. .gitmodules stays.
|
||||
- name: Fetch the framework submodule over HTTPS
|
||||
env:
|
||||
GIT_CONFIG_COUNT: "2"
|
||||
GIT_CONFIG_KEY_0: url.https://git.tirsystem.com/.insteadOf
|
||||
GIT_CONFIG_VALUE_0: ssh://git@git.tirsystem.com:10022/
|
||||
GIT_CONFIG_KEY_1: http.https://git.tirsystem.com/.extraheader
|
||||
GIT_CONFIG_VALUE_1: "Authorization: token ${{ secrets.TOKEN_FOR_GITEA_ISSUES }}"
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Make sure curl and jq are available
|
||||
run: bash framework/scripts/ci-publish-milestones.sh ensure-tools
|
||||
|
||||
- name: Publish accepted milestones
|
||||
env:
|
||||
# Gitea reads GITEA_TOKEN, GitHub's gh reads GH_TOKEN; both come from
|
||||
# the one secret named above.
|
||||
GITEA_TOKEN: ${{ secrets.SYNC_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.SYNC_TOKEN }}
|
||||
# Gitea reads TOKEN_FOR_GITEA_ISSUES, GitHub's gh reads GH_TOKEN; both
|
||||
# come from the one secret named above.
|
||||
TOKEN_FOR_GITEA_ISSUES: ${{ secrets.TOKEN_FOR_GITEA_ISSUES }}
|
||||
GH_TOKEN: ${{ secrets.TOKEN_FOR_GITEA_ISSUES }}
|
||||
# Empty on a push (a real run); "true" or "false" on a manual run.
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: bash framework/scripts/ci-publish-milestones.sh publish
|
||||
|
||||
@@ -92,6 +92,8 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
|
||||
$env:SNACK_AND_LIFT_PASSWORD = "your password"
|
||||
```
|
||||
|
||||
Instead of setting variables by hand, you can put the same lines in a file named `.env.gym` in the project folder (`SNACK_AND_LIFT_EMAIL=...` and `SNACK_AND_LIFT_PASSWORD=...`, one per line). The bot reads it at start; `--env-file PATH` names another file, and a real environment variable wins over the file. The file `.env.gym` is ignored by git: never commit it. Two optional lines, `SNACK_AND_LIFT_ADMIN_EMAIL` and `SNACK_AND_LIFT_ADMIN_PASSWORD`, hold the admin account of the practice site; the bot loads them but does not use them yet.
|
||||
|
||||
### Start the bot
|
||||
|
||||
With the environment active:
|
||||
|
||||
@@ -14,9 +14,9 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| BC | Business Case | docs/business-case.md | 002 |
|
||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 010 |
|
||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 011 |
|
||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 024 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 028 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 878 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 878 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,74 @@
|
||||
# MIL-010 Gym accounts from a credentials file
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | MIL-010 |
|
||||
| CrossReference | [BC-001], [MIL-003] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version, requested by S01 in chat | [434bdaf] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Decide whether the bot can read the student and the admin account of the practice site from the personal file `.env.gym`, so that S01 does not set environment variables by hand before each run, while the file stays out of the repository and the tests.
|
||||
|
||||
## Deliverable
|
||||
|
||||
`credentials.py` that loads the student account and, load-only, the admin account from the environment, plus a small reader of the `.env.gym` file in the infrastructure layer that the command line merges under the real environment, unit tests with dummy values only, and README and wiki instructions.
|
||||
|
||||
## Go / No-Go Criteria
|
||||
|
||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Unit tests show that the student and the admin account load from file values, and that a real environment variable wins over the file | 0 failures | A test fails or is missing |
|
||||
| 2 | Unit tests show that a missing or empty variable raises an error that names the variable and never prints a value, and that the admin account is optional | 0 failures | A test fails or is missing |
|
||||
| 3 | No test reads `.env` or `.env.gym`; `.env.gym` is in `.gitignore`; no password appears in the repository | `git grep` finds no real credential; tests use `tmp_path` | A real file is read or a credential is committed |
|
||||
| 4 | All earlier tests pass with at least 80% coverage of the logic modules | Both true | Either false |
|
||||
| 5 | The code review against `qc-programming-python.md` is `Go` | `RC-*` is `Go` | `No-Go` |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Depends on | Reason |
|
||||
| --- | --- |
|
||||
| MIL-003 | Changes the credentials handling that MIL-003 built |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Business Case objective / KPI / user story | Reference |
|
||||
| --- | --- |
|
||||
| O2, O7 | [BC-001] |
|
||||
| CN-004, CN-005, CN-009 | [SA-001] |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Role | Stakeholder ID (SA) |
|
||||
| --- | --- |
|
||||
| Owner | S01 |
|
||||
| Approving reviewer | S02 (AI as stand-in, autonomous mode, not independent) |
|
||||
|
||||
## Target Date
|
||||
|
||||
2026-11-06 — inside the planned window 2026-10-28 to 2026-11-06 of [PP-001]; the Business Case sets no deadline.
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Load the student and the admin account | In `credentials.py` read `SNACK_AND_LIFT_EMAIL` and `SNACK_AND_LIFT_PASSWORD` (required) and `SNACK_AND_LIFT_ADMIN_EMAIL` and `SNACK_AND_LIFT_ADMIN_PASSWORD` (optional, load-only; nothing uses the admin yet). Errors name the variables, never a value. | No | O2 |
|
||||
| 2 | Read the `.env.gym` file | Add a small reader (no new dependency) in `infrastructure/` for `KEY=VALUE` lines; the command line merges the file under the real environment, and a missing file is not an error. | No | O2, O7 |
|
||||
| 3 | Test and document | Unit tests with dummy values in `tmp_path`; update the README and the Getting Started wiki pages (three languages) and keep `.env.gym` in `.gitignore`. | No | O7, O8 |
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
[MIL-003]: ./mil-003-login.md
|
||||
[PP-001]: ../project-plan.md
|
||||
[SA-001]: ../stakeholder-analysis.md
|
||||
[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f
|
||||
@@ -4,21 +4,21 @@
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | PP-001 |
|
||||
| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007], [MIL-008], [MIL-009] |
|
||||
| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007], [MIL-008], [MIL-009], [MIL-010] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version: eight gateways | [50e8331] |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-009, a verification fix found in a real run (S01 accepted the plan in chat) | [3b9e381] |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-010, gym accounts from a credentials file (S01 asked for it in chat) | [434bdaf] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
This plan schedules nine gateways (`MIL-001` to `MIL-009`) that take the Snack and Lift booking bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-06 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch.
|
||||
This plan schedules ten gateways (`MIL-001` to `MIL-010`) that take the Snack and Lift booking bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-06 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch.
|
||||
|
||||
## Planning Assumptions
|
||||
|
||||
@@ -41,6 +41,7 @@ This plan schedules nine gateways (`MIL-001` to `MIL-009`) that take the Snack a
|
||||
| Lecture step 9 | [MIL-007] | 2026-11-02 to 2026-11-04 | 2026-11-04 | S01 | none (technical tasks) | `retry`, resilient `login`, `book_class`, `get_my_bookings`, screenshot 9 | |
|
||||
| Documentation, wiki and release | [MIL-008] | 2026-11-04 to 2026-11-06 | 2026-11-06 | S01 | none (documentation) | README, Doxygen, wiki in en, da and ms, final checks | |
|
||||
| Verification fix | [MIL-009] | 2026-11-04 to 2026-11-06 | 2026-11-06 | S01 | none (technical tasks) | Verification compares only the window of the run; new screenshots 7 to 9 | |
|
||||
| Gym accounts from a file | [MIL-010] | 2026-11-04 to 2026-11-06 | 2026-11-06 | S01 | none (technical tasks) | `.env.gym` credentials for the student and the admin account | |
|
||||
|
||||
```plantuml
|
||||
@startgantt
|
||||
@@ -79,7 +80,7 @@ Project starts 2026-10-28
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 → MIL-008 → MIL-009
|
||||
MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 → MIL-008 → MIL-009 → MIL-010
|
||||
```
|
||||
|
||||
A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed. The end date then moves by the days spent on the rework; S01 decides a move of the end date.
|
||||
@@ -112,5 +113,7 @@ A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed.
|
||||
[MIL-007]: ./milestones/mil-007-resilience.md
|
||||
[MIL-008]: ./milestones/mil-008-documentation.md
|
||||
[MIL-009]: ./milestones/mil-009-verification-fix.md
|
||||
[MIL-010]: ./milestones/mil-010-credentials-file.md
|
||||
[50e8331]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/50e8331bd21910a72ae56b74c9ff6a3677a709b3
|
||||
[3b9e381]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/3b9e381cd42c56cc4543869c5b80c8e0014b4948
|
||||
[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-025 |
|
||||
| CrossReference | [MIL-010], [QC-MIL-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review | [434bdaf] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-010]
|
||||
- Checklist used: [QC-MIL-001] (`QC-MIL-001`)
|
||||
- Scope: full review
|
||||
- Language and domain: en / it
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | A concrete deliverable is defined for every gate | Pass | Deliverable section names concrete files, runs or documents. |
|
||||
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Go/No-Go table has objectively checkable criteria with Go and No-Go columns. |
|
||||
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Dependencies table names the preceding milestone and the reason. |
|
||||
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceability cites O2 and O7 of BC-001 and the concerns of SA-001. |
|
||||
| 5 | Milestone owner and approving reviewer are identified | Pass | Ownership names S01 as owner and S02 (AI as stand-in) as approving reviewer. |
|
||||
| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target Date lies inside the window 2026-10-28 to 2026-11-06 of PP-001. |
|
||||
|
||||
## Language and Domain Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Metadata has `Language` = en and `Domain` = it. |
|
||||
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. |
|
||||
| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells of MIL-010 are English (stand-in assessment, see the verdict). |
|
||||
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register of the type is given in the registry (Languages); the text keeps to it. |
|
||||
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | Terms follow DICT-001: bot, booking, verification (stand-in assessment, see the verdict). |
|
||||
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Keys, headings, IDs and statuses are English. |
|
||||
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists in `docs/`. |
|
||||
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | N-A | First version; no language change. |
|
||||
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | The stand-in reviewer reads English and checked the IT terms in context (stand-in assessment, see the verdict). |
|
||||
| 10 | Abbreviations are spelled out on first use, in the stated language | Pass | Abbreviations are spelled out or defined where first used, or are the framework's own IDs. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — MIL-010 meets every Mandatory criterion. No blocking finding. The review is not independent: the AI authored and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary. Criteria 3, 5 and 9 of QC-LANG-001 were assessed by the stand-in only; the framework would hold them `Pending` for an independent language reviewer. S01 is asked to confirm them in the end-of-run summary.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | S01 | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-010]: ../../milestones/mil-010-credentials-file.md
|
||||
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
|
||||
[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f
|
||||
@@ -0,0 +1,62 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-026 |
|
||||
| CrossReference | [MIL-010], [QC-PY-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review of the source and tests of MIL-010 | [42f05bd] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-010]
|
||||
- Checklist used: [QC-PY-001] (`QC-PY-001`)
|
||||
- Scope: full review of the source and tests of MIL-010
|
||||
- Language and domain: n/a
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules, functions and variables are `snake_case`, classes `PascalCase`, constants `UPPER_SNAKE`; `ruff check` with the N rules passes. |
|
||||
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (for example `retry`, `login`, `book_class`, `get_my_bookings`); no single-letter names outside comprehensions. |
|
||||
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` leaves 0 files changed and `ruff check` reports 0 findings; there is no suppression. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every function and method has annotations (ruff ANN rules pass), including `-> None`. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; specific exceptions are caught, and re-raised with `raise ... from` where the cause matters. |
|
||||
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments; no builtin is shadowed (ruff B and A-style checks pass). |
|
||||
| 7 | Files, locks and connections are managed with context managers | Pass | Files and the browser are managed with `with`/`try-finally`; no unmanaged file handle exists. |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings (`@brief`, `@param`, `@return`). |
|
||||
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No print calls; messages go through `logging`. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; ADR-0001 records that design artifacts are skipped. Classes follow ADR-0002. |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 273 unit tests need no browser or network (new: the admin account, half-set variables, the env file parser, precedence of the environment, `--env-file`); an autouse fixture keeps every CLI test in `tmp_path`, so no test can read `.env` or `.env.gym`; 21 container tests pass; coverage of the non-infrastructure modules is 100%. |
|
||||
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | N-A | No strict type checker is configured; annotations are complete and checked by ruff. |
|
||||
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` declares Selenium (runtime) and pytest, pytest-cov, ruff (dev) with lower bounds; none is unused. |
|
||||
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Each rule, constant and message exists once (constants in `constants.py`, rules in `domain/`). |
|
||||
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` and `application` import no Selenium; only `infrastructure` imports it; no import cycle exists. |
|
||||
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | One reason to change per module; the adapter hides Selenium behind a port; fakes honour the port contract. |
|
||||
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layout follows `src/snack_and_lift/{domain,application,infrastructure}` and `tests/{unit,integration,e2e}`; there is no presentation layer for a command-line bot (ADR-0002). |
|
||||
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | All file and folder names are English lowercase with `_`, `-` and `.` only. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — the code of MIL-010 meets every Mandatory criterion of QC-PY-001. No blocking finding. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | S01 | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-010]: ../../milestones/mil-010-credentials-file.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[42f05bd]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/42f05bdd0f5c300fbd162e8cf50075fc98964e1b
|
||||
@@ -0,0 +1,69 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-027 |
|
||||
| CrossReference | [MIL-008], [QC-WF-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-29 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, the workflow sync-milestones.yml after the framework update to 0.18.0 and the removal of publish-wiki.yml | [2cfcd02] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-008]
|
||||
- Checklist used: [QC-WF-001] (`QC-WF-001`)
|
||||
- Scope: the workflow sync-milestones.yml after the framework update to 0.18.0 and the removal of publish-wiki.yml
|
||||
- Language and domain: n/a
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The file meets the YAML checklist ([QC-YML-001]) | Pending | `yamllint` is not installed on the stand-in's machine, so the YAML checklist QC-YML-001 was not run; the files parse (actionlint reads them) and use 2-space indentation. |
|
||||
| 2 | The file is in `.gitea/workflows/` or `.github/workflows/` with the extension `.yml` or `.yaml`, and the same workflow does not exist in both directories | Pass | `sync-milestones.yml` is in `.gitea/workflows/` with the extension `.yml` and not in `.github/workflows/`. `publish-wiki.yml` was removed (see the verdict). |
|
||||
| 3 | The file declares `name`, `on` and `jobs`; `on` lists explicit events and, where it matters, `branches` or `paths` | Pass | The file declares `name`, `on` and `jobs`; `on` lists `push` on `main` with `paths`, and `workflow_dispatch`. |
|
||||
| 4 | A header comment states the purpose, the triggers, the inputs, the secrets it needs, what it changes outside the repository, and the decision or milestone it implements | Pass | The header states the purpose, triggers, secrets, what changes outside the repository and the installation steps (from the framework template); the decision is MIL-008 task 5. The added step has its own comment. |
|
||||
| 5 | Every job and step has a descriptive `name`, unique within its job | Pass | Every step has a descriptive name. |
|
||||
| 6 | The file passes `actionlint` (or the project's workflow validation test) and the tool version is stated | Pass | `actionlint` 1.7.12 reports no finding (exit code 0). |
|
||||
| 7 | Every job declares `permissions` explicitly with the least access that works (`contents: read` or none by default); write access only on the job that needs it | Pass | The job sets `contents: read`. |
|
||||
| 8 | Every action used is pinned to a full commit SHA or an exact release tag, never to a branch; a moving major tag (`@v1`) is accepted only for an action published by the same organisation under a stated release policy | Pass | `actions/checkout@v4.2.2` is an exact release tag. |
|
||||
| 9 | No secret is written in the file; a secret is read as `secrets.NAME`, passed to a step through `env:`, never placed on a command line or echoed | Pass | The file reads `secrets.TOKEN_FOR_GITEA_ISSUES` only; no secret value is in the repository. The token reaches git through `GIT_CONFIG_*` environment variables, never a command line. |
|
||||
| 10 | Untrusted text (`inputs.*`, event fields such as a title, branch name or label, step outputs derived from them) is never interpolated into a `run:` script; it is passed through `env:` and used as a quoted variable | Pass | No `run:` block interpolates `inputs.*` or event text; `DRY_RUN` is passed through `env:`. |
|
||||
| 11 | A trigger that gives a run secrets or a write token on code from a fork or an untrusted branch (`pull_request_target`, `workflow_run`) does not check out or execute that code | Pass | The workflow does not use `pull_request_target` or `workflow_run`. |
|
||||
| 12 | A workflow that changes state outside the repository (release, tag, publish, sync, deploy) is started by hand or by a protected branch, and a manual run defaults to a dry run with an explicit input to apply | Pass | It runs on a push to `main` or by hand; the manual run defaults to a dry run. |
|
||||
| 13 | The token the workflow uses has only the scope the job needs, and where the host's built-in token is enough it is used instead of a personal token | Pending | The scope of the token is set by S01; the dry run on 2026-10-28 got HTTP 403 on the framework repository, so the token lacks read access to it. |
|
||||
| 14 | Every job sets `runs-on` to an explicit label and `timeout-minutes` | Pass | The job sets `runs-on: ubuntu-latest` and `timeout-minutes: 15`. |
|
||||
| 15 | `concurrency` is set where two runs would conflict: a check cancels the older run (`cancel-in-progress: true`); a publishing job queues (`cancel-in-progress: false`) | Pass | `concurrency` is set with `cancel-in-progress: false`. |
|
||||
| 16 | Triggers are filtered (`branches`, `paths`, `types`) so the workflow runs only for the changes it concerns | Pass | The triggers are filtered by `branches: [main]` and `paths`. |
|
||||
| 17 | Tools and dependencies are installed at pinned versions; a version is a workflow input with a default or a value in one place | Pass | The workflow installs nothing itself; `curl` and `jq` are checked by the framework script. |
|
||||
| 18 | A dependency cache is used where the install is slow enough to matter | N-A | No dependency is installed that a cache would speed up. |
|
||||
| 19 | A failing step names the tool, the file and the reason in its log (annotation or `::error::` line) | Pending | Not verified: the log messages come from the framework script and the reusable workflow, which the stand-in did not run. |
|
||||
| 20 | A `run:` block is within the limit on embedded program text of criterion 7 of [QC-YML-001]; a longer script is a file (`tools/`, or a shared repository fetched at a pinned ref) that the step calls with one command, and the workflow passes its settings through `env:` | Pass | Each `run:` block is one command; the logic is in `framework/scripts/ci-publish-milestones.sh`. |
|
||||
| 21 | Logic used by more than one workflow or repository is a reusable workflow (`workflow_call`) or a composite action; each input has a `description`, a `type` and a `default` where one is sensible | Pass | The workflow calls one framework script; the input of `workflow_dispatch` has a description, type and default. |
|
||||
| 22 | Every workflow feature used is verified on the instance that runs it, and the host and version are recorded; a feature the target hosts differ on is noted in the header | Pending | A dry run on the instance (2026-10-28, run 3034, branch `fix-sync-milestones-submodule`) passed the HTTPS rewrite and then failed with 403 on the framework repository, so the feature is not yet verified end to end. |
|
||||
| 23 | A reusable workflow states, in its header, how a caller pins it and the script it fetches, so the workflow and its script are the same release | N-A | The file is not a reusable workflow. |
|
||||
| 24 | A static test of the repository's workflows exists and runs on every pull request: valid YAML, required keys, explicit permissions and runner, no inline script above the limit | N-A | No static test of the workflows exists in this project; the checks are `actionlint` on request. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go-with-conditions - the milestone workflow is the framework template (0.18.0) plus one step that fetches the framework submodule over HTTPS, and it passes `actionlint`. `publish-wiki.yml` is removed because the framework retired the caller: the instance publishes `docs/wiki/` with its own scoped workflow (RC-022 reviewed the old caller). Mandatory criteria 1 (`yamllint` was not run) and 22 (feature checked on the instance) are Pending: the token lacks read access to the framework repository. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Give the token `TOKEN_FOR_GITEA_ISSUES` read access to the repository TirSystem/SQA-QC-Framework, create the secret in the organization and run the workflow once by hand with dry_run on | S01 | before the next merge to main |
|
||||
| Run `yamllint` on `.gitea/workflows/` | S01 | when convenient |
|
||||
|
||||
---
|
||||
|
||||
[MIL-008]: ../../milestones/mil-008-documentation.md
|
||||
[QC-WF-001]: ../../../framework/qc/code/qc-workflow.md
|
||||
[2cfcd02]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/2cfcd0258897aeb90cea48baf59a8af258df19dd
|
||||
@@ -23,7 +23,7 @@ Tracks backward and forward links between artifact instances so that the Busines
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| [BC-001] | Business Case | en | it | - | [SA-001], [PP-001] | RC-001 |
|
||||
| [SA-001] | Stakeholder Analysis | en | it | [BC-001] | [PP-001] | RC-002 |
|
||||
| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-009] | RC-003 |
|
||||
| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-010] | RC-003 |
|
||||
| [DICT-001] | Domain Dictionary | en | it | [BC-001], [SA-001] | - | RC-004 |
|
||||
| [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-005 |
|
||||
| [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code from MIL-002 onward | RC-006 |
|
||||
@@ -37,13 +37,14 @@ Tracks backward and forward links between artifact instances so that the Busines
|
||||
| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-014 |
|
||||
| [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 |
|
||||
| [MIL-009] | Milestone | en | it | [BC-001], [MIL-006] | - | RC-023 |
|
||||
| [MIL-010] | Milestone | en | it | [BC-001], [MIL-003] | - | RC-025 |
|
||||
| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002], [ADR-0003] | - | RC-016 |
|
||||
| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002], [ADR-0003] | - | RC-017 (Go-with-conditions) |
|
||||
| Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-018 (Go-with-conditions) |
|
||||
| Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-019 (Go-with-conditions) |
|
||||
| Source and tests of [MIL-006] | Python source code | - | - | [MIL-006], [ADR-0002] | - | RC-020 (Go-with-conditions) |
|
||||
| Source and tests of [MIL-007] | Python source code | - | - | [MIL-007], [ADR-0002] | - | RC-021 (Go-with-conditions) |
|
||||
| Workflows and wiki of [MIL-008] | Workflow files, wiki pages | - | - | [MIL-008] | - | RC-022 (Go-with-conditions) |
|
||||
| Workflows and wiki of [MIL-008] | Workflow files, wiki pages | - | - | [MIL-008] | - | RC-022, RC-027 (Go-with-conditions) |
|
||||
|
||||
## Concern Coverage
|
||||
|
||||
@@ -77,4 +78,5 @@ Tracks backward and forward links between artifact instances so that the Busines
|
||||
[MIL-007]: ../milestones/mil-007-resilience.md
|
||||
[MIL-008]: ../milestones/mil-008-documentation.md
|
||||
[MIL-009]: ../milestones/mil-009-verification-fix.md
|
||||
[MIL-010]: ../milestones/mil-010-credentials-file.md
|
||||
[50e8331]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/50e8331bd21910a72ae56b74c9ff6a3677a709b3
|
||||
|
||||
@@ -39,6 +39,10 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
|
||||
$env:SNACK_AND_LIFT_PASSWORD = "your password"
|
||||
```
|
||||
|
||||
## Kontofilen
|
||||
|
||||
I stedet for at sætte variabler i hånden kan du skrive de samme linjer i en fil `.env.gym` i projektmappen (`SNACK_AND_LIFT_EMAIL=...` og `SNACK_AND_LIFT_PASSWORD=...`). Botten læser den ved start; `--env-file PATH` peger på en anden fil, og en rigtig miljøvariabel vinder over filen. Git ignorerer `.env.gym`: læg den aldrig i repositoriet. De valgfrie `SNACK_AND_LIFT_ADMIN_EMAIL` og `SNACK_AND_LIFT_ADMIN_PASSWORD` gemmer øvelsessidens admin-konto; botten indlæser dem, men bruger dem ikke endnu.
|
||||
|
||||
## Kør og test
|
||||
|
||||
```bash
|
||||
|
||||
@@ -39,6 +39,10 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
|
||||
$env:SNACK_AND_LIFT_PASSWORD = "your password"
|
||||
```
|
||||
|
||||
## Fail akaun
|
||||
|
||||
Daripada menetapkan pemboleh ubah secara manual, letakkan baris yang sama dalam fail `.env.gym` di folder projek (`SNACK_AND_LIFT_EMAIL=...` dan `SNACK_AND_LIFT_PASSWORD=...`). Bot membacanya semasa bermula; `--env-file PATH` menamakan fail lain, dan pemboleh ubah persekitaran sebenar mengatasi fail. Git mengabaikan `.env.gym`: jangan sekali-kali commit. `SNACK_AND_LIFT_ADMIN_EMAIL` dan `SNACK_AND_LIFT_ADMIN_PASSWORD` yang pilihan menyimpan akaun admin laman latihan; bot memuatkannya tetapi belum menggunakannya.
|
||||
|
||||
## Jalankan dan uji
|
||||
|
||||
```bash
|
||||
|
||||
@@ -39,6 +39,10 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
|
||||
$env:SNACK_AND_LIFT_PASSWORD = "your password"
|
||||
```
|
||||
|
||||
## The account file
|
||||
|
||||
Instead of setting variables by hand, put the same lines in a file `.env.gym` in the project folder (`SNACK_AND_LIFT_EMAIL=...` and `SNACK_AND_LIFT_PASSWORD=...`). The bot reads it at start; `--env-file PATH` names another file, and a real environment variable wins over the file. Git ignores `.env.gym`: never commit it. The optional `SNACK_AND_LIFT_ADMIN_EMAIL` and `SNACK_AND_LIFT_ADMIN_PASSWORD` hold the practice site's admin account; the bot loads them but does not use them yet.
|
||||
|
||||
## Run and test
|
||||
|
||||
```bash
|
||||
|
||||
@@ -30,6 +30,34 @@ def load_credentials(environment: Mapping[str, str]) -> Credentials:
|
||||
if not email or not password:
|
||||
raise MissingCredentialsError(
|
||||
f"Set {constants.ACCOUNT_EMAIL_ENV} and {constants.ACCOUNT_PASSWORD_ENV} "
|
||||
"in the environment (they must not be empty)"
|
||||
"in the environment or the env file (they must not be empty)"
|
||||
)
|
||||
return Credentials(email=email, password=password)
|
||||
|
||||
|
||||
def load_admin_credentials(environment: Mapping[str, str]) -> Credentials | None:
|
||||
"""@brief Read the optional admin account from the environment.
|
||||
|
||||
@param environment The environment variables, for example `os.environ`.
|
||||
@return The credentials, or None when neither variable is set.
|
||||
@exception MissingCredentialsError Only one of the two variables is set; the message
|
||||
names the missing variable and never a value.
|
||||
"""
|
||||
email = environment.get(constants.ADMIN_EMAIL_ENV, "").strip()
|
||||
password = environment.get(constants.ADMIN_PASSWORD_ENV, "")
|
||||
if not email and not password:
|
||||
return None
|
||||
if not email:
|
||||
raise MissingCredentialsError(_admin_missing(constants.ADMIN_EMAIL_ENV))
|
||||
if not password:
|
||||
raise MissingCredentialsError(_admin_missing(constants.ADMIN_PASSWORD_ENV))
|
||||
return Credentials(email=email, password=password)
|
||||
|
||||
|
||||
def _admin_missing(name: str) -> str:
|
||||
"""@brief Build the message for a half-set admin account.
|
||||
|
||||
@param name The variable that is missing or empty.
|
||||
@return The message; it names the variable only.
|
||||
"""
|
||||
return constants.MESSAGE_ADMIN_HALF_SET.format(name=name)
|
||||
|
||||
@@ -12,7 +12,11 @@ from pathlib import Path
|
||||
|
||||
from snack_and_lift import constants
|
||||
from snack_and_lift.application.booking import book_next_tuesday, book_target_classes
|
||||
from snack_and_lift.application.credentials import Credentials, load_credentials
|
||||
from snack_and_lift.application.credentials import (
|
||||
Credentials,
|
||||
load_admin_credentials,
|
||||
load_credentials,
|
||||
)
|
||||
from snack_and_lift.application.ports import GymPort
|
||||
from snack_and_lift.application.retry import Sleep
|
||||
from snack_and_lift.application.session import login_with_retry
|
||||
@@ -23,6 +27,7 @@ from snack_and_lift.domain.report import format_detail_list, format_summary
|
||||
from snack_and_lift.domain.verification import format_verification
|
||||
from snack_and_lift.infrastructure.browser import create_driver, default_profile_dir
|
||||
from snack_and_lift.infrastructure.console_recorder import ConsoleRecorder
|
||||
from snack_and_lift.infrastructure.env_file import read_env_file
|
||||
from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
|
||||
|
||||
## Builds the gym site from (headless, profile folder, keep open).
|
||||
@@ -69,7 +74,8 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.",
|
||||
epilog=(
|
||||
"exit codes: 0 done, 1 a step failed (also when every retry failed), "
|
||||
"2 credentials missing, 3 the My Bookings page does not match the bookings made"
|
||||
"2 credentials missing in the environment or the env file, "
|
||||
"3 the My Bookings page does not match the bookings made"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -110,6 +116,16 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
f"drops a request (default: {constants.RETRY_COUNT}, at least 1)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--env-file",
|
||||
type=Path,
|
||||
default=Path(constants.ENV_FILE_NAME),
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"file with the gym accounts as KEY=VALUE lines "
|
||||
f"(default: ./{constants.ENV_FILE_NAME}); a real environment variable wins"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.keep_open is None:
|
||||
args.keep_open = not args.headless
|
||||
@@ -242,7 +258,9 @@ def _main(
|
||||
_use_utf8_output()
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
credentials = load_credentials(os.environ)
|
||||
environment = {**read_env_file(args.env_file), **os.environ}
|
||||
credentials = load_credentials(environment)
|
||||
load_admin_credentials(environment) # load-only for now; nothing uses it yet
|
||||
except MissingCredentialsError as error:
|
||||
logger.error("%s", error)
|
||||
return 2
|
||||
|
||||
@@ -70,6 +70,19 @@ ACCOUNT_EMAIL_ENV: Final[str] = "SNACK_AND_LIFT_EMAIL"
|
||||
## Name of the environment variable that holds the account password.
|
||||
ACCOUNT_PASSWORD_ENV: Final[str] = "SNACK_AND_LIFT_PASSWORD"
|
||||
|
||||
## Names of the environment variables that hold the admin account (read, not used yet).
|
||||
ADMIN_EMAIL_ENV: Final[str] = "SNACK_AND_LIFT_ADMIN_EMAIL"
|
||||
ADMIN_PASSWORD_ENV: Final[str] = "SNACK_AND_LIFT_ADMIN_PASSWORD"
|
||||
|
||||
## Error for an admin account with one variable only; {name} is the missing variable.
|
||||
MESSAGE_ADMIN_HALF_SET: Final[str] = (
|
||||
"Set {name} too: the admin account needs both "
|
||||
"SNACK_AND_LIFT_ADMIN_EMAIL and SNACK_AND_LIFT_ADMIN_PASSWORD"
|
||||
)
|
||||
|
||||
## Default file with the gym accounts, relative to the current directory.
|
||||
ENV_FILE_NAME: Final[str] = ".env.gym"
|
||||
|
||||
## Folder where screenshots of a container run are written.
|
||||
SCREENSHOT_DIR: Final[str] = "docs/images"
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""@file env_file.py
|
||||
@brief Reads `KEY=VALUE` lines from an env file (never logs a value).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
## Prefix a line may carry so the file can also be sourced by a shell.
|
||||
_EXPORT_PREFIX = "export "
|
||||
|
||||
## Quote characters that may surround a value.
|
||||
_QUOTES = ("'", '"')
|
||||
|
||||
|
||||
def _unquote(value: str) -> str:
|
||||
"""@brief Strip one pair of matching surrounding quotes.
|
||||
|
||||
@param value The text after the `=`.
|
||||
@return The value without the surrounding pair, otherwise unchanged.
|
||||
"""
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in _QUOTES:
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def read_env_file(path: Path) -> dict[str, str]:
|
||||
"""@brief Read the variables of an env file.
|
||||
|
||||
Blank lines, `#` comments, lines without `=` and an empty key are skipped.
|
||||
|
||||
@param path The file.
|
||||
@return The variables; empty when the file does not exist.
|
||||
"""
|
||||
if not path.is_file():
|
||||
return {}
|
||||
variables: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, _, value = stripped.partition("=")
|
||||
key = key.strip().removeprefix(_EXPORT_PREFIX).strip()
|
||||
if key:
|
||||
variables[key] = _unquote(value.strip())
|
||||
return variables
|
||||
@@ -18,6 +18,12 @@ DUMMY_EMAIL = "dummy@example.invalid"
|
||||
DUMMY_PASSWORD = "dummy-password"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Run from an empty folder so the default env file is never a real one."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def credentials_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL)
|
||||
@@ -324,3 +330,68 @@ def test_main_passes_options_to_the_factory(tmp_path: Path) -> None:
|
||||
main(["--headless", "--profile-dir", str(tmp_path)], factory)
|
||||
|
||||
assert received == [(True, tmp_path, False)]
|
||||
|
||||
|
||||
def _write_env_file(path: Path, email: str, password: str) -> Path:
|
||||
path.write_text(
|
||||
f"{constants.ACCOUNT_EMAIL_ENV}={email}\n"
|
||||
f"export {constants.ACCOUNT_PASSWORD_ENV}='{password}'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def test_env_file_defaults_to_the_gym_file_name() -> None:
|
||||
assert parse_args([]).env_file == Path(constants.ENV_FILE_NAME)
|
||||
|
||||
|
||||
def test_env_file_option_is_read(tmp_path: Path) -> None:
|
||||
assert parse_args(["--env-file", str(tmp_path / "x.env")]).env_file == tmp_path / "x.env"
|
||||
|
||||
|
||||
def test_main_reads_the_account_from_the_env_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False)
|
||||
monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False)
|
||||
env_file = _write_env_file(tmp_path / "accounts.env", "file@example.invalid", "file-pass")
|
||||
gym = FakeGym()
|
||||
|
||||
code = main(["--env-file", str(env_file)], lambda *_: gym)
|
||||
|
||||
assert code == 0
|
||||
assert gym.logins == [("file@example.invalid", "file-pass")]
|
||||
|
||||
|
||||
def test_main_lets_a_real_environment_variable_win_over_the_env_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL)
|
||||
monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD)
|
||||
env_file = _write_env_file(tmp_path / "accounts.env", "file@example.invalid", "file-pass")
|
||||
gym = FakeGym()
|
||||
|
||||
code = main(["--env-file", str(env_file)], lambda *_: gym)
|
||||
|
||||
assert code == 0
|
||||
assert gym.logins == [(DUMMY_EMAIL, DUMMY_PASSWORD)]
|
||||
|
||||
|
||||
def test_main_exits_2_when_neither_environment_nor_file_has_the_account(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False)
|
||||
monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False)
|
||||
|
||||
assert main(["--env-file", str(tmp_path / "absent.env")], lambda *_: FakeGym()) == 2
|
||||
|
||||
|
||||
def test_main_exits_2_when_the_admin_account_is_half_set(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL)
|
||||
monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD)
|
||||
monkeypatch.setenv(constants.ADMIN_EMAIL_ENV, "admin@example.invalid")
|
||||
monkeypatch.delenv(constants.ADMIN_PASSWORD_ENV, raising=False)
|
||||
|
||||
assert main(["--env-file", str(tmp_path / "absent.env")], lambda *_: FakeGym()) == 2
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
import pytest
|
||||
|
||||
from snack_and_lift import constants
|
||||
from snack_and_lift.application.credentials import Credentials, load_credentials
|
||||
from snack_and_lift.application.credentials import (
|
||||
Credentials,
|
||||
load_admin_credentials,
|
||||
load_credentials,
|
||||
)
|
||||
from snack_and_lift.domain.errors import MissingCredentialsError
|
||||
|
||||
DUMMY_PASSWORD = "dummy-secret-value"
|
||||
@@ -42,3 +46,51 @@ def test_load_credentials_rejects_missing_or_empty(environment: dict[str, str])
|
||||
|
||||
def test_repr_hides_the_password() -> None:
|
||||
assert DUMMY_PASSWORD not in repr(Credentials("dummy@example.invalid", DUMMY_PASSWORD))
|
||||
|
||||
|
||||
def test_load_admin_credentials_reads_both_variables() -> None:
|
||||
environment = {
|
||||
constants.ADMIN_EMAIL_ENV: " admin@example.invalid ",
|
||||
constants.ADMIN_PASSWORD_ENV: DUMMY_PASSWORD,
|
||||
}
|
||||
|
||||
assert load_admin_credentials(environment) == Credentials(
|
||||
"admin@example.invalid", DUMMY_PASSWORD
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"environment",
|
||||
[
|
||||
{},
|
||||
{constants.ADMIN_EMAIL_ENV: "", constants.ADMIN_PASSWORD_ENV: ""},
|
||||
{constants.ADMIN_EMAIL_ENV: " "},
|
||||
],
|
||||
)
|
||||
def test_load_admin_credentials_is_none_when_not_set(environment: dict[str, str]) -> None:
|
||||
assert load_admin_credentials(environment) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("environment", "missing"),
|
||||
[
|
||||
({constants.ADMIN_EMAIL_ENV: "admin@example.invalid"}, constants.ADMIN_PASSWORD_ENV),
|
||||
(
|
||||
{constants.ADMIN_EMAIL_ENV: "admin@example.invalid", constants.ADMIN_PASSWORD_ENV: ""},
|
||||
constants.ADMIN_PASSWORD_ENV,
|
||||
),
|
||||
({constants.ADMIN_PASSWORD_ENV: DUMMY_PASSWORD}, constants.ADMIN_EMAIL_ENV),
|
||||
(
|
||||
{constants.ADMIN_EMAIL_ENV: "", constants.ADMIN_PASSWORD_ENV: DUMMY_PASSWORD},
|
||||
constants.ADMIN_EMAIL_ENV,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_load_admin_credentials_rejects_half_set(environment: dict[str, str], missing: str) -> None:
|
||||
with pytest.raises(MissingCredentialsError) as caught:
|
||||
load_admin_credentials(environment)
|
||||
|
||||
message = str(caught.value)
|
||||
assert message.startswith(f"Set {missing} too")
|
||||
assert DUMMY_PASSWORD not in message
|
||||
assert "admin@example.invalid" not in message
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""@file test_env_file.py
|
||||
@brief The env file reader parses KEY=VALUE lines (dummy values in tmp_path only).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from snack_and_lift.infrastructure.env_file import read_env_file
|
||||
|
||||
|
||||
def _read(tmp_path: Path, text: str) -> dict[str, str]:
|
||||
path = tmp_path / "accounts.env"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return read_env_file(path)
|
||||
|
||||
|
||||
def test_read_env_file_returns_empty_when_file_is_missing(tmp_path: Path) -> None:
|
||||
assert read_env_file(tmp_path / "absent.env") == {}
|
||||
|
||||
|
||||
def test_read_env_file_reads_pairs(tmp_path: Path) -> None:
|
||||
assert _read(tmp_path, "A=1\nB=two\n") == {"A": "1", "B": "two"}
|
||||
|
||||
|
||||
def test_read_env_file_skips_blank_lines_comments_and_lines_without_equals(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
text = "\n# comment\n # indented comment\nnot a pair\nA=1\n"
|
||||
|
||||
assert _read(tmp_path, text) == {"A": "1"}
|
||||
|
||||
|
||||
def test_read_env_file_accepts_export_prefix(tmp_path: Path) -> None:
|
||||
assert _read(tmp_path, "export A=1\n") == {"A": "1"}
|
||||
|
||||
|
||||
def test_read_env_file_strips_whitespace_around_key(tmp_path: Path) -> None:
|
||||
assert _read(tmp_path, " A =1\n") == {"A": "1"}
|
||||
|
||||
|
||||
def test_read_env_file_strips_one_pair_of_quotes(tmp_path: Path) -> None:
|
||||
text = "A=\"dummy one\"\nB='dummy two'\nC=\"'both'\"\n"
|
||||
|
||||
assert _read(tmp_path, text) == {"A": "dummy one", "B": "dummy two", "C": "'both'"}
|
||||
|
||||
|
||||
def test_read_env_file_keeps_unmatched_quotes_and_lone_quote(tmp_path: Path) -> None:
|
||||
assert _read(tmp_path, 'A="dummy\'\nB="\n') == {"A": "\"dummy'", "B": '"'}
|
||||
|
||||
|
||||
def test_read_env_file_keeps_equals_and_hash_inside_value(tmp_path: Path) -> None:
|
||||
assert _read(tmp_path, "A=x=y#z\n") == {"A": "x=y#z"}
|
||||
|
||||
|
||||
def test_read_env_file_ignores_empty_key(tmp_path: Path) -> None:
|
||||
assert _read(tmp_path, "=value\n") == {}
|
||||
|
||||
|
||||
def test_read_env_file_keeps_empty_value(tmp_path: Path) -> None:
|
||||
assert _read(tmp_path, "A=\n") == {"A": ""}
|
||||