itsulu-blog-publisher/CLAUDE.md
Nicholas Riegel b4d1e577df release: v0.4.8
Introduce the project versioning system (MAJOR.MINOR.PATCH, each 0-999)
and tag the first release.

- VERSION file as single source of truth (0.4.8)
- __manifest__.py version -> 17.0.0.4.8 (odoo series + product version)
- CHANGELOG.md with plain-language v0.4.8 release notes
- scripts/bump-version.sh: bump (major/minor/patch/set) + tag from CHANGELOG
- README.md: version header, 69-test status, changelog link
- CLAUDE.md §15 Versioning & Releases; corrected Odoo 17 mail.template /
  blog.post.content compatibility table

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 03:27:18 -04:00

1018 lines
No EOL
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: odoo-testing-framework
description: >
Use this skill whenever the user is writing, reviewing, debugging, or planning tests
for an Odoo Community module. Trigger phrases: "write a test", "add a unit test",
"pytest-odoo", "TransactionCase", "BDD scenario", "Gherkin", "fixture", "conftest",
"test is failing", "coverage", "factory", "assertQueryCount", "pre-commit",
"CI pipeline", "template database", "Runboat", "Playwright", "E2E test",
"CLAUDE.md", "GitLab CI", "ArgoCD", "how do I test", "vibe code Odoo".
Also trigger when the user opens or edits any file matching
tests/test_*.py, features/*.feature, conftest.py, or .gitlab-ci.yml in an Odoo repo.
---
# Odoo Testing Framework Skill
This skill encodes the complete ITSulu testing framework for Odoo Community modules.
Read it top to bottom the first time you consult it in a session; return to specific
sections as needed during development.
---
## 0. The Golden Rules (read these first every time)
1. **Test first.** Write a failing test before any implementation code. Never write
implementation and tests at the same time.
2. **One behaviour per test.** Each test method verifies exactly one assertion.
The method name is the specification: `test_gold_tier_gets_ten_percent_discount`.
3. **Isolate with TransactionCase.** Unit tests roll back automatically. Never
clean up the database manually.
4. **Template databases, always.** Every CI job that runs tests must use a primed
template DB. Never install Odoo from scratch per test class.
5. **1030 E2E scenarios, maximum.** Playwright is for critical user journeys only.
6. **BDD only when stakeholders read Gherkin.** If they don't, use TDD with
descriptive names. Do not generate Gherkin no-one will read.
7. **Mask all CI secrets.** Never commit tokens, passwords, or keys to YAML.
8. **Every addition is pain-driven.** Add a tool only when a concrete problem demands
it, not because the roadmap has a box for it.
---
## 1. Repository Layout
```
<repo-root>/
├── CLAUDE.md # Conventions file — Claude Code reads this
├── .pre-commit-config.yaml
├── .gitlab-ci.yml
├── addons/
│ └── <module>/
│ ├── __manifest__.py
│ ├── models/
│ ├── tests/
│ │ ├── __init__.py
│ │ ├── test_<feature>.py # TransactionCase unit tests
│ │ └── factories.py # OdooFactory for test data
│ └── features/ # Only if using BDD
│ └── <feature>.feature
├── e2e/ # Playwright scenarios
│ ├── conftest.py
│ └── test_<journey>.py
└── conftest.py # Template DB fixture lives here
```
---
## 2. TDD Cycle
```
RED → write test describing one behaviour (must fail)
GREEN → write minimum code to make it pass
REFACTOR → clean up; test stays green
REPEAT → next behaviour
```
Cycle time target: **515 minutes per behaviour**.
Never skip RED. Never write code before the test exists.
---
## 3. Writing pytest-odoo Tests
### 3.1 TransactionCase (default — use for almost everything)
```python
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install', 'sale')
class TestSaleOrderDiscount(TransactionCase):
"""Loyalty discount rules on sale order lines."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Expensive shared setup — runs once per class
cls.product = cls.env['product.product'].create({
'name': 'Widget', 'list_price': 100.0, 'type': 'consu'
})
def _make_order(self, tier):
"""Helper — always use helpers for repeated data creation."""
partner = self.env['res.partner'].create({
'name': f'Customer-{tier}', 'loyalty_tier': tier
})
return self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': 1.0,
})]
})
def test_gold_tier_gets_ten_percent_discount(self):
order = self._make_order('gold')
self.assertEqual(order.order_line.discount, 10.0)
def test_silver_tier_gets_five_percent_discount(self):
order = self._make_order('silver')
self.assertEqual(order.order_line.discount, 5.0)
def test_no_tier_gets_no_discount(self):
order = self._make_order(False)
self.assertEqual(order.order_line.discount, 0.0)
```
**Rules:**
- Class name: `TestXxxYyy` — describes the unit under test.
- Method name: complete sentence — `test_what_when_condition`.
- `setUpClass`: shared, expensive objects (products, categories, pricelists).
- `setUp` / inline: test-specific data (partners, orders, invoices).
- Do **not** create shared mutable state; each test must be independent.
**Critical: Never call `self.env.cr.commit()` in code being tested by TransactionCase**
- TransactionCase uses database savepoints; each test gets rolled back automatically
- `commit()` breaks the savepoint chain, causing `InFailedSqlTransaction` errors in subsequent tests
- **Use `self.env.flush_all()` instead** to persist changes while keeping the test isolated
- If you need to test explicit commits, use `TestCase` instead (slower, no auto-rollback)
### 3.2 HttpCase (use rarely — only for controllers or UI tours)
```python
from odoo.tests import HttpCase
class TestSaleOrderUI(HttpCase):
def test_create_order_tour(self):
self.start_tour('/odoo/sales', 'sale_order_tour', login='admin')
```
Use HttpCase only when the test genuinely requires a running controller or JavaScript tour.
It is ~10× slower than TransactionCase.
### 3.3 Test tags
```python
@tagged('post_install', '-at_install') # Standard for module tests
@tagged('post_install', '-at_install', 'sale') # + module-specific tag
@tagged('post_install', '-at_install', 'performance') # Performance tests
```
Run subsets:
```bash
pytest addons/ -m sale # Only sale-tagged tests
pytest addons/ -m "not slow" # Exclude slow tests
```
### 3.4 Common assertions
```python
self.assertEqual(record.field, expected)
self.assertIn(item, collection)
self.assertTrue(condition)
self.assertFalse(condition)
self.assertRaises(ValidationError, lambda: record.write({'field': bad_value}))
# Performance assertion — always add to hot paths
with self.assertQueryCount(50):
records.action_confirm()
```
---
## 4. Test Data Factories
Use factories, not demo data. Demo data changes; factories are explicit contracts.
```python
# addons/<module>/tests/factories.py
class OdooFactory:
def __init__(self, env):
self.env = env
self._seq = 0
def _next(self):
self._seq += 1
return self._seq
def partner(self, **kw):
vals = {'name': f'Partner-{self._next()}', 'email': f'p{self._next()}@test.com'}
vals.update(kw)
return self.env['res.partner'].create(vals)
def product(self, **kw):
vals = {'name': f'Product-{self._next()}', 'list_price': 100.0, 'type': 'consu'}
vals.update(kw)
return self.env['product.product'].create(vals)
def sale_order(self, partner=None, lines=None, **kw):
partner = partner or self.partner()
lines = lines or [{'product_id': self.product().id, 'product_uom_qty': 1}]
vals = {
'partner_id': partner.id,
'order_line': [(0, 0, l) for l in lines],
}
vals.update(kw)
return self.env['sale.order'].create(vals)
```
Use in tests:
```python
from .factories import OdooFactory
class TestMyFeature(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = OdooFactory(cls.env)
def test_something(self):
order = self.factory.sale_order(lines=[
{'product_id': self.factory.product(list_price=200).id, 'product_uom_qty': 2}
])
self.assertEqual(order.amount_untaxed, 400.0)
```
---
## 5. Template Database (conftest.py)
This is the single biggest speed optimisation. Always use it.
```python
# conftest.py (repo root)
import os
import pytest
import psycopg2
TEMPLATE_DB = os.environ.get('TEMPLATE_DATABASE', 'odoo_primed')
PG = dict(
host=os.environ.get('POSTGRES_HOST', 'localhost'),
user=os.environ.get('POSTGRES_USER', 'odoo'),
password=os.environ.get('POSTGRES_PASSWORD', 'odoo'),
dbname='postgres',
)
@pytest.fixture(scope='class')
def clone_db(request):
"""Clone the primed template database for this test class. Drop on teardown."""
db_name = f"test_{request.cls.__name__.lower()}_{os.getpid()}"
conn = psycopg2.connect(**PG)
conn.autocommit = True
conn.execute(f"CREATE DATABASE {db_name} TEMPLATE {TEMPLATE_DB}")
conn.close()
yield db_name
conn = psycopg2.connect(**PG)
conn.autocommit = True
conn.execute(f"DROP DATABASE IF EXISTS {db_name}")
conn.close()
```
CI before_script:
```yaml
before_script:
- createdb -h postgres -U odoo odoo_primed
- odoo -d odoo_primed -i sale,your_module --without-demo=all --stop-after-init
```
Expected speedup: **820× faster** test setup (typically ~12×).
---
## 6. BDD with pytest-bdd
Only add BDD when stakeholders read and contribute to feature files.
### 6.1 Feature file
```gherkin
# addons/sale_loyalty/features/loyalty_discount.feature
Feature: Loyalty discount on sale orders
As a sales manager
I want loyalty tier discounts applied automatically
So that high-value customers feel valued
Scenario Outline: Discount by tier
Given a customer with loyalty tier "<tier>"
When I create a sale order with 1 Widget
Then the order line discount should be <discount>%
Examples:
| tier | discount |
| gold | 10 |
| silver | 5 |
```
### 6.2 Step definitions
```python
# tests/test_loyalty_bdd.py
import pytest
from pytest_bdd import scenarios, given, when, then, parsers
scenarios('../features/loyalty_discount.feature')
# CRITICAL: pytest-bdd scenarios expect 'odoo_env' fixture, not pytest-odoo's 'env'.
# Use request.getfixturevalue('env') — direct env injection fails in pytest-bdd context:
@pytest.fixture
def odoo_env(request):
"""pytest-odoo's env fixture, re-exported for BDD step access."""
return env
@pytest.fixture
def ctx():
return {}
@given(parsers.parse('a customer with loyalty tier "{tier}"'), target_fixture='customer')
def make_customer(odoo_env, tier):
return odoo_env['res.partner'].create({'name': f'BDD-{tier}', 'loyalty_tier': tier})
@when(parsers.parse('I create a sale order with {qty:d} Widget'))
def create_order(odoo_env, customer, qty, ctx):
product = odoo_env['product.product'].search([('name', '=', 'Widget')], limit=1)
ctx['order'] = odoo_env['sale.order'].create({
'partner_id': customer.id,
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': qty})],
})
@then(parsers.parse('the order line discount should be {pct:d}%'))
def assert_discount(ctx, pct):
assert ctx['order'].order_line[0].discount == pct
```
---
## 7. Playwright E2E
### 7.1 Core rules
- Maximum **1030 scenarios**. Do not grow past this.
- Run against **Runboat live URLs**, never bootstrap Odoo inside the CI job.
- Selectors: `data-test-id` on custom views, `get_by_role`, `get_by_label`.
- Never use auto-generated Odoo CSS classes (`.o_form_view`, `.o_field_widget`, etc.).
- Add `data-test-id` to your XML views: `<field name="discount" data-test-id="line-discount"/>`.
- Set default timeout ≥ 30 seconds (Odoo JS is slow to load).
- Poll `BASE_URL + /web/login` until 200 before the suite starts (Runboat cold-start).
### 7.2 Session fixture
```python
# e2e/conftest.py
import pytest
import requests
import time
BASE_URL = os.environ['ODOO_BASE_URL'] # Set by CI from Runboat
def wait_for_odoo(url, timeout=180):
deadline = time.time() + timeout
while time.time() < deadline:
try:
r = requests.get(f"{url}/web/login", timeout=5)
if r.status_code == 200:
return
except Exception:
pass
time.sleep(5)
raise TimeoutError(f"Odoo at {url} not ready after {timeout}s")
@pytest.fixture(scope='session')
def browser_context_args(browser_context_args):
wait_for_odoo(BASE_URL)
return {**browser_context_args, 'base_url': BASE_URL}
@pytest.fixture(scope='session')
def auth_state(browser, browser_context_args):
ctx = browser.new_context(**browser_context_args)
page = ctx.new_page()
page.goto('/web/login')
page.get_by_label('Email').fill('admin')
page.get_by_label('Password').fill('admin')
page.get_by_role('button', name='Log in').click()
page.wait_for_url('**/odoo/**')
state = ctx.storage_state()
ctx.close()
return state
@pytest.fixture
def page(browser, browser_context_args, auth_state):
ctx = browser.new_context(**browser_context_args, storage_state=auth_state)
pg = ctx.new_page()
pg.set_default_timeout(30_000)
yield pg
ctx.close()
```
### 7.3 Example scenario
```python
# e2e/test_generation.py
from playwright.sync_api import expect
def test_user_generates_blog_post_on_demand(page):
"""Navigate to Blog Publisher, fill form, generate post, verify published."""
page.goto('/odoo/blog/generate-now')
page.wait_for_load_state('networkidle')
page.get_by_label('Topic').fill('Kubernetes Cost Optimization')
page.get_by_label('LLM Provider').select_option('anthropic')
page.get_by_label('Auto-publish').check()
page.get_by_role('button', name='Generate').click()
page.wait_for_url('**/blog/**', timeout=60_000)
expect(page.locator('h1')).to_contain_text('Kubernetes')
```
### 7.4 Runboat Integration
**Runboat** provides ephemeral preview instances of Odoo per CI commit:
- **Auto-deployment**: Fresh Odoo instance with addon pre-installed
- **Live URL**: For E2E tests (no local bootstrapping)
- **Auto-cleanup**: Instance removed 5 minutes after test completion
- **Template DB**: Primed once, cloned for each test
**Cold-start Handling**:
```python
def wait_for_odoo(url, timeout=180):
"""Poll until Odoo responds (instance startup takes 30-60s)."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
if requests.get(f"{url}/web/login", timeout=5).status_code == 200:
return
except Exception:
pass
time.sleep(2)
raise TimeoutError(f"Odoo not ready after {timeout}s")
```
**CI/CD Integration**:
```yaml
# .gitlab-ci.yml
runboat_preview:
stage: preview
script: |
RESP=$(curl -fsSL -X POST $RUNBOAT_URL/builds \
-H "Authorization: Bearer $RUNBOAT_TOKEN" \
-d "{\"repo\":\"$CI_PROJECT_PATH\",\"sha\":\"$CI_COMMIT_SHA\"}")
BUILD_URL=$(echo "$RESP" | jq -r '.url')
echo "BUILD_URL=$BUILD_URL" >> build.env
e2e_tests:
stage: e2e
needs: [runboat_preview]
script: pytest e2e/ --base-url=$BUILD_URL -v
```
---
## 8. Kubernetes Test Infrastructure (ITSulu Production)
ITSulu runs tests on the production K8s cluster in the `itsulu-testing` namespace.
Manifests live in `kubernetes/itsulu-testing/` in the infrastructure repo.
### 8.1 Architecture
```
itsulu-testing namespace
├── PostgreSQL 15 (deploy-test-db.yaml) # persistent, always running
│ └── odoo_template DB # primed once with all modules
├── K8s Secret: test-db-info # username, password, database keys
├── K8s Secret: gitlab-docker-creds # image pull from GitLab registry
└── Job: blog-publisher-bdd-test-<timestamp> # ephemeral per test run
├── initContainer: setup-test-db # postgres:15-alpine, createdb from template
└── container: test-runner # our Docker image, runs pytest
```
### 8.2 Docker Image (Dockerfile)
```dockerfile
FROM odoo:17.0
# odoo:17.0 runs as non-root 'odoo' user — pip installs go to ~/.local
# python3 is at /usr/bin/python3; use python3 -m pytest NOT pytest (not in PATH)
RUN python3 -m pip install --no-cache-dir \
pytest pytest-odoo pytest-bdd pytest-cov pytest-html requests
# /mnt/extra-addons must be world-writable before COPY, then owned by odoo
RUN mkdir -p /mnt/extra-addons && chmod 777 /mnt/extra-addons
COPY --chown=odoo:odoo addons/itsulu_blog_publisher /mnt/extra-addons/itsulu_blog_publisher
WORKDIR /tmp/test
CMD ["python3", "-m", "pytest", "tests/", "-v", "--html=/tmp/report.html", "--self-contained-html"]
```
**Critical gotchas learned:**
- `odoo:17.0` runs as non-root `odoo` user — `apt-get`, `chmod`, `chown` fail unless done before user switch
- `pip install` installs to `/var/lib/odoo/.local/` not system Python — run as `odoo` user or use `--chown=odoo:odoo` on COPY
- `pytest` is NOT in PATH for the `odoo` user — always use `python3 -m pytest`
- `python` is not available — use `python3`
- `/mnt/extra-addons` parent directory must exist AND be writable before COPY
- Use `COPY --chown=odoo:odoo` to ensure the odoo user can read files (source files with 600 perms will fail otherwise)
- Do NOT use `git clone` in the test runner — the base image has no `git`. The addon is already COPY'd into the image.
- `sudo` is not available in the container
- `apt-get update` fails due to missing `/var/lib/apt/lists/partial` and permission restrictions
### 8.3 Running a Test Job
```bash
# Create and run a test job (use a fixed DB name — not $RANDOM between containers)
TIMESTAMP=$(date +%s) && kubectl apply -f - <<EOF
apiVersion: batch/v1
kind: Job
metadata:
name: blog-publisher-bdd-test-${TIMESTAMP}
namespace: itsulu-testing
spec:
backoffLimit: 1
ttlSecondsAfterFinished: 3600
template:
spec:
serviceAccountName: test-runner
restartPolicy: Never
imagePullSecrets:
- name: gitlab-docker-creds
initContainers:
- name: setup-test-db
image: postgres:15-alpine
command: [sh, -c, |
until pg_isready -h test-db-svc -U $DB_USER; do sleep 2; done
PGPASSWORD=$DB_PASSWORD createdb -h test-db-svc -U $DB_USER -T odoo_template odoo_test || true]
env: [...] # DB_USER, DB_PASSWORD from test-db-info secret
containers:
- name: test-runner
image: registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest
imagePullPolicy: Always
command: [/bin/bash, -c, |
python3 -m pytest /mnt/extra-addons/itsulu_blog_publisher/tests -v
--odoo-database=odoo_test --html=/tmp/report.html --self-contained-html || true]
env: [...] # DB_USER, DB_PASSWORD from test-db-info secret
volumeMounts: [{name: test-results, mountPath: /tmp}]
volumes: [{name: test-results, emptyDir: {sizeLimit: 1Gi}}]
EOF
# Watch
kubectl get pods -n itsulu-testing -w
# Logs
kubectl logs -n itsulu-testing job/blog-publisher-bdd-test-${TIMESTAMP} -f
# Copy HTML report
kubectl cp itsulu-testing/<pod-name>:/tmp/report.html ./report.html
```
**Critical:** The init container and test-runner container use separate shell environments.
Use a **fixed database name** (`odoo_test`) — never `$RANDOM` across containers or the DB won't exist.
### 8.4 Manual Secrets (ExternalSecrets not installed)
```bash
# DB credentials
kubectl create secret generic test-db-info \
--from-literal=username=odoo_test \
--from-literal=password='<password>' \
--from-literal=database=odoo_template \
-n itsulu-testing
# GitLab registry credentials
kubectl create secret docker-registry gitlab-docker-creds \
--docker-server=registry.gitlab.com \
--docker-username=<gitlab-username> \
--docker-password=<gitlab-pat-token> \
-n itsulu-testing
```
### 8.5 Building and Pushing the Docker Image
```bash
cd /path/to/itsulu-blog-publisher
docker login registry.gitlab.com # use GitLab username + PAT token
# Build (use --no-cache when Dockerfile changes don't seem to apply)
docker build --no-cache -t registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest .
docker push registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest
```
---
## 9. GitLab CI Pipeline
```yaml
# .gitlab-ci.yml (skeleton)
stages: [lint, test, build, preview, e2e]
variables:
ODOO_IMAGE: registry.example.com/oca/oca-ci/py3.11-odoo17.0:latest
POSTGRES_DB: odoo_test
POSTGRES_USER: odoo
# POSTGRES_PASSWORD → set as masked CI/CD variable, never inline
lint:
stage: lint
image: $ODOO_IMAGE
script:
- pre-commit run --all-files
- pylint-odoo --rcfile=.pylintrc-odoo addons/
unit_tests:
stage: test
image: $ODOO_IMAGE
services:
- name: postgres:15
alias: postgres
command: ["postgres", "-c", "fsync=off", "-c", "shared_buffers=512MB"]
before_script:
- createdb -h postgres -U odoo odoo_primed
- odoo -d odoo_primed -i sale,your_module --without-demo=all --stop-after-init
script:
- pytest addons/ --odoo-database=odoo_primed --template-database=odoo_primed
--cov=addons --cov-report=xml --junitxml=report.xml
artifacts:
when: always
reports:
junit: report.xml
coverage_report: {coverage_format: cobertura, path: coverage.xml}
build_image:
stage: build
image: gcr.io/kaniko-project/executor:debug
script:
- /kaniko/executor --context=. --dockerfile=Dockerfile
--destination=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
runboat_build:
stage: preview
image: curlimages/curl:latest
script:
- |
RESP=$(curl -fsSL -X POST $RUNBOAT_URL/builds \
-H "Authorization: Bearer $RUNBOAT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"repo":"'"$CI_PROJECT_PATH"'","sha":"'"$CI_COMMIT_SHA"'"}')
BUILD_URL=$(echo $RESP | jq -r .url)
echo "BUILD_URL=$BUILD_URL" >> build.env
- |
for i in $(seq 1 36); do
curl -sf "$BUILD_URL/web/login" && break || sleep 5
done
- 'curl -sf -X POST "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes"
-H "PRIVATE-TOKEN: $GITLAB_BOT_TOKEN"
--data "body=Preview: $BUILD_URL"'
artifacts:
reports: {dotenv: build.env}
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
playwright_e2e:
stage: e2e
image: mcr.microsoft.com/playwright/python:latest
needs: [runboat_build]
script:
- pip install -r e2e/requirements.txt
- pytest e2e/ --base-url=$BUILD_URL --tracing=retain-on-failure
artifacts:
when: on_failure
paths: [e2e/traces/]
expire_in: 1 week
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
```
### CI secrets — always mask
| Variable | Where to set | Notes |
|---|---|---|
| `POSTGRES_PASSWORD` | GitLab CI/CD variables | Masked |
| `RUNBOAT_TOKEN` | GitLab CI/CD variables | Masked + Protected |
| `GITLAB_BOT_TOKEN` | GitLab CI/CD variables | Masked + Protected |
| `CI_REGISTRY_PASSWORD` | Auto (GitLab) | Built-in |
Never put secrets in `.gitlab-ci.yml`. Never `echo $SECRET` in scripts.
---
## 10. Pre-commit Configuration
```yaml
# .pre-commit-config.yaml
repos:
- repo: https://github.com/OCA/pylint-odoo
rev: v9.1.0
hooks:
- id: pylint-odoo
additional_dependencies: [pylint-odoo]
- repo: https://github.com/psf/black
rev: 24.3.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/pycqa/flake8
rev: 7.0.0
hooks:
- id: flake8
additional_dependencies: [flake8-bugbear]
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
```
Run locally: `pre-commit run --all-files`
Run on commit: automatic after `pre-commit install`
---
## 11. CLAUDE.md Template
Copy this to the repo root and fill in the blanks. Claude Code reads this automatically.
```markdown
# CLAUDE.md
## Project
Odoo 17.0 Community — ITSulu
Framework: pytest-odoo (unit), pytest-bdd (BDD if stakeholders engage), Playwright (E2E)
Deploy: GitLab CI → Kaniko → ArgoCD → ITSulu Kubernetes
## Layout
addons/<module>/ Odoo addon code
addons/<module>/tests/ Unit tests (TransactionCase)
addons/<module>/features/ Gherkin features (BDD, when used)
e2e/ Playwright E2E scenarios
.gitlab-ci.yml Six-stage CI pipeline
## Development workflow — STRICT TDD
1. Write a failing test. Show it to me for approval.
2. I confirm the test matches intent before you write code.
3. Write minimum code to make it pass.
4. Run the full test class. Confirm green.
5. Refactor if needed. Test stays green.
6. Run pre-commit before considering the task done.
7. Never proceed to step 3 before step 2 is complete.
## Test naming
Class: TestXxxYyy (describes unit under test)
Method: test_what_when_condition (complete sentence, underscore-separated)
Good: test_gold_tier_customer_gets_ten_percent_discount
Bad: test_discount, test1, test_it_works
## When to use BDD
Only when a business stakeholder reads the feature file. Ask me first.
Default: TDD only with descriptive test names.
## Commands
pytest addons/ Run all tests
pytest addons/ -k TestSaleOrderDiscount Run one class
pytest addons/ -m "not slow" Exclude tagged tests
pytest e2e/ --base-url=$BUILD_URL E2E against Runboat
pre-commit run --all-files Lint + format
## Patterns to follow
- Use OdooFactory from tests/factories.py for record creation
- Use assertQueryCount on hot paths (confirm budget with me first)
- Add data-test-id to custom view fields used by Playwright
- One assertion per test method (exceptions need justification)
## Patterns to avoid
- Never create records using demo data or pre-seeded fixtures
- Never bypass pre-commit hooks
- Never silence test failures with try/except or xfail without issue ref
- Never write "assert True" or trivially-passing tests
- Never combine setup + assertion in one line
## When in doubt
Stop and ask me before: creating a new addon, modifying __manifest__,
changing a model that another module inherits, any sudo() or ir.rule change.
Show options with trade-offs. I will choose.
```
---
## 11.5 Odoo 17 Model Field Compatibility
When extending core Odoo models (e.g., `blog.post`), be aware of field differences:
| Model | Field | Odoo 16 | Odoo 17 | Note |
|-------|-------|---------|---------|------|
| `blog.post` | `body_arch` | Yes | **No** | Removed in Odoo 17 |
| `blog.post` | `body` | Yes | **No** | Removed in Odoo 17 |
| `blog.post` | `content` | — | **Yes** | The HTML body field in Odoo 17 — **writable**; auto-generation MUST write `'content': llm_response.body_html` or posts publish empty |
| `blog.post` | `itsulu_social_id` | N/A | Custom | Add via `_inherit` for reverse relationship to custom models |
| `mail.template` | subject / email_from | Mako `${}` | **inline_template `{{ }}`** | Mako `${}` is dead in Odoo 17 — renders literally |
| `mail.template` | body_html | Mako `${}`/`% for` | **qweb `type="html"`** | Use `<t t-out="">`, `<t t-foreach t-as>`, `<t t-if>` as real XML children. NOT `type="qweb"` (invalid RNG type) |
| `mail.template` | render method | `generate_email()` | **`_render_field(field, [ids])`** | Returns `{id: rendered_str}`; `_generate_template` returns UNrendered text |
**Pattern for extending blog.post safely:**
```python
# addons/<module>/models/blog_post.py
class BlogPost(models.Model):
_inherit = 'blog.post'
custom_field_id = fields.One2many(
comodel_name='custom.model',
inverse_name='blog_post_id',
string='Custom Records',
)
```
**Pattern for creating blog.post test records:**
```python
# Use sudo() to bypass Odoo validation when creating minimal test posts.
# The body field in Odoo 17 is `content` (NOT body / body_arch / body_html).
post = self.env['blog.post'].sudo().create({
'name': 'Test Post',
'blog_id': blog.id,
'is_published': True,
'content': '<p>HTML body goes here</p>', # Odoo 17 body field — writable
})
```
**Pattern for asserting on a rendered mail.template (synchronous):**
```python
template = self.env.ref('module.email_template_xmlid')
rendered = template._render_field('subject', [record.id]) # {id: 'rendered text'}
assert 'Expected' in rendered[record.id]
# body_html: template._render_field('body_html', [record.id])
```
---
## 12. Failure Recovery Quick Reference
| Symptom | Most likely cause | Fix |
|---|---|---|
| All tests fail with `relation does not exist` | Template DB corrupted / stale | Rebuild: `dropdb odoo_primed && createdb odoo_primed && odoo -d odoo_primed -i ...` |
| E2E first scenario fails, rest pass | Runboat not ready | Add readiness poll: `curl -sf $URL/web/login \|\| sleep 5` in a loop |
| E2E selector breaks after Odoo update | Auto-generated class changed | Replace with `data-test-id` attribute or `get_by_role` |
| Pipeline stuck "initializing" | Runboat cluster resource exhaustion | Increase namespace ResourceQuota; close stale MRs |
| Flaky test fails > twice/week | Non-deterministic test | Quarantine with `@pytest.mark.skip(reason="flaky #123")`; fix within 1 week |
| Secret appears in CI logs | Variable not masked | Rotate immediately; add to masked variables in GitLab |
| ArgoCD stuck OutOfSync | Manual cluster change | `argocd app diff`; revert manual change; re-sync |
| K8s Job: `PermissionError: __manifest__.py` | Files copied without `--chown=odoo:odoo` | Rebuild image with `COPY --chown=odoo:odoo` in Dockerfile |
| K8s Job: `No module named pytest` | Ran as wrong user (root); pytest in odoo user's ~/.local | Run container as odoo user (default); use `python3 -m pytest` not `pytest` |
| K8s Job: `database does not exist` | Init container used `$RANDOM` — different from test-runner | Use fixed DB name (`odoo_test`) shared between containers |
| K8s Job: `ErrImagePull` | `gitlab-docker-creds` secret missing or expired | Recreate: `kubectl create secret docker-registry gitlab-docker-creds ...` |
| K8s Job: `CreateContainerConfigError` | Secret key missing (e.g. `database` key not in `test-db-info`) | `kubectl describe pod <pod>` to find missing key; recreate secret with all 3 keys |
| Docker build: `chmod: Operation not permitted` | odoo:17.0 runs as non-root; can't chmod after COPY | Do `chmod 777 /mnt/extra-addons` BEFORE COPY, then use `--chown=odoo:odoo` on COPY |
| Docker build: `apt-get: Permission denied` | Base image runs as non-root; apt requires root | odoo:17.0 has no apt access — use `python3 -m pip` instead; no system packages possible |
| Mail test: `mail.mail.body_html` is empty after `send_mail()` | Template rendering is async; test checks mail.mail synchronously | Use `force_send=True` in send_mail(); check mail.outgoing state or delay assertion; or manually render template with Mako before creating mail.mail |
| Mail test: `mail.mail.subject` is `False` instead of string | Template subject field not rendered by send_mail() | Verify template fields are populated in DB; render manually if needed |
| Odoo 17: `blog.post` doesn't accept `body` or `body_arch` fields | Fields renamed/removed in Odoo 17 | Use factory pattern with `.sudo().create()` to bypass validation; post content should be managed via editor interface, not direct assignment |
| Odoo 17: Inverse relationship not loaded in template context | Relations lazy-load; mail.template context may not include reverse relations | Use explicit search in Mako (`for loop`) instead of relying on inverse field; or fetch record with prefetch in the render context |
| Odoo 17: `mail.template` has no `generate_email()` | Method removed in Odoo 17; API changed entirely | Use `template._generate_template([res_id], ['subject', 'body_html'])` — positional args only, returns `{res_id: {field: value}}`. Never use `generate_email(res_id)` (old Odoo 16 API) |
| conftest.py inside addon dir causes `Invalid import` | pytest adds addon dir to sys.path, bypassing `odoo.addons.*` namespace | Never put conftest.py inside the addon package. Place at repo root or at `/mnt/extra-addons/conftest.py` (parent of addon dir) |
| BDD test: `fixture 'env' not found` (pytest-odoo 2.x) | pytest-odoo 2.x does NOT provide an `env` pytest fixture — env only exists as `self.env` inside TransactionCase | In BDD step fixtures, build env directly: `registry = odoo.registry(request.config.getoption('--odoo-database')); with registry.cursor() as cr: env = Environment(cr, odoo.SUPERUSER_ID, {}); yield env; cr.rollback()` |
| Odoo 17: `mail.template` body uses `${}`/`% for` Mako and renders literally | Pre-Odoo-14 Mako syntax is dead in Odoo 17 | subject/email_from use inline_template `{{ }}`; body_html uses `type="html"` with qweb `<t t-out=""/>`, `<t t-foreach t-as>`, `<t t-if>`. NOT `type="qweb"` (invalid RNG type → "Element odoo has extra content: data") |
| Odoo 17: `mail.template._render_field('subject', [id])` returns `{id: rendered_str}` | This is the real synchronous render method | Use `_render_field(field, res_ids)` to assert on rendered output; `_generate_template` returns UNrendered template text |
| Data-XML change (e.g. mail.template) not reflected after image rebuild | Template DB was primed once; clones inherit stale records | Re-prime or run `odoo -u <module> -d odoo_template --stop-after-init`. Translatable fields (subject) DO update on `-u` once the XML is valid |
| Test passes alone but fails in full suite | Stale image at pod startup, or mock attr mismatch | Re-run fresh in the pod to confirm; a MagicMock attr the code reads but the test never set stringifies to ~66 chars (e.g. body_html vs text) |
| Odoo 17: `blog.post` has no `body_arch`/`body` | Renamed in Odoo 17 | The HTML body field is `content`. Auto-generation MUST write `'content': llm_response.body_html` or posts publish empty |
| Test: `IndexError: tuple index out of range` when accessing `mock.call_args[0][0]` | Mock method called with keyword-only args; `call_args[0]` is empty tuple `()` | Use `mock.call_args[1].get('key')` for kwargs; or check `mock.called` before accessing `call_args` |
| Test: TransactionCase gets `InFailedSqlTransaction` in subsequent tests | Previous test called `self.env.cr.commit()` breaking savepoint chain | Replace `commit()` with `flush_all()` in code being tested; `commit()` is only allowed in non-test code in production |
| Test: Mock response returns HTML but code expects JSON | Mock return values must match the data format expected by code under test | Create helper function to generate mocks with correct structure (e.g., JSON string in `.text` field for LLM routers) |
| Test: Calling mocked service with wrong parameter name | Test uses different parameter name than actual service signature | Match test calls to actual method signatures (e.g., `topic=` not `prompt=` for LLMRouter.generate()) |
| BDD test: `fixture 'env' not found` in odoo_env | pytest-bdd cannot inject pytest-odoo's `env` fixture by name into conftest fixtures | Use `request.getfixturevalue('env')` instead of `def odoo_env(env)` — and place the fixture in the BDD test file itself, not only in conftest; conftest in addon dirs triggers import errors |
---
## 13. Performance SLO Targets
### Test Infrastructure
| Metric | Target | Why |
|---|---|---|
| Unit test suite duration | < 3 min | Keeps TDD cycles tight |
| Full pipeline P50 | < 15 min | Fast enough to wait for |
| Full pipeline P95 | < 25 min | Outliers do not ruin the day |
| Flaky test rate | < 2%/week | Maintains trust in suite |
| Coverage on new code | 80% | Enforces test-first habit |
| Runboat cold-start P95 | < 120 s | E2E does not time out |
### Generation Performance (Phase 3)
| Metric | Target | Measurement |
|---|---|---|
| Generation latency P50 | < 30 seconds | RED POST created |
| Generation latency P99 | < 60 seconds | 99th percentile |
| Tokens per post | 8001200 | Cost baseline |
| Queries per generation | < 50 | N+1 detection |
| Concurrent posts | 5+ | Peak throughput |
| Email send latency | < 5 seconds | Notification speed |
| Template DB prime | < 60 seconds | CI/CD overhead |
**Measurement Tools**:
```python
# Latency profiling
import time
start = time.monotonic()
post = schedule.run_generation()
elapsed = time.monotonic() - start
assert elapsed < 30 # P50 target
# Query count assertion
with self.assertQueryCount(50):
schedule.run_generation() # Must use < 50 queries
# Token usage logging
log.tokens_used # Recorded in generation log
assert 800 <= log.tokens_used <= 1200
```
---
## 14. Production Deploy
---
## 15. Versioning & Releases
The project uses a three-part version number, **`MAJOR.MINOR.PATCH`**, each part `0999`.
| Part | Bump when | Examples |
|---|---|---|
| **MAJOR** | A major release for sale: significant feature upgrade, or a significant change to the software. | new product tier, rewrite, breaking redesign |
| **MINOR** | One or more features added, or a meaningful performance improvement. | new LLM provider, new wizard, 2× faster generation |
| **PATCH** | A single group of commits, or one large commit. | bug-fix batch, doc pass, refactor, dependency bump |
### Sources of truth & where the version lives
The repo-root **`VERSION`** file is the single source of truth. These are kept in sync:
| File | Form | Notes |
|---|---|---|
| `VERSION` | `0.4.8` | Source of truth plain text, one line |
| `addons/itsulu_blog_publisher/__manifest__.py` | `'version': '17.0.0.4.8'` | Odoo manifest = `<odoo_series>.<product_version>` = `17.0` + `0.4.8` |
| `README.md` | `**Version:** 0.4.8` | Header line + status footer |
| `CHANGELOG.md` | `## v0.4.8 — <date>` | Release notes section per version |
| git tag | `v0.4.8` | Annotated tag; message = the CHANGELOG section |
**Never hand-edit the version in multiple files.** Use the helper script it updates all of
them from `VERSION` and derives the Odoo manifest version (`17.0.<MAJOR>.<MINOR>.<PATCH>`).
### Release process (every version change)
```bash
# 1. Bump the version (updates VERSION, manifest, README)
scripts/bump-version.sh patch # or: minor | major | set X.Y.Z
# 2. Write the release notes — add a "## vX.Y.Z — <date>" section to CHANGELOG.md
# in plain, common language (what changed, why it matters), NOT git-speak.
# Group under Fixed / Added / Changed / Testing & Infrastructure / Documentation.
# 3. Commit the bump + notes together
git add -A && git commit -m "release: vX.Y.Z"
# 4. Tag the release — the tag message is taken from the CHANGELOG section
scripts/bump-version.sh tag
# 5. Push branch and tag
git push && git push origin vX.Y.Z
```
### Release-notes style (common language)
Write for a teammate, not a compiler. Each entry says **what changed and why it matters** in
one or two sentences of plain English. Prefer:
> - **Notification emails now render correctly.** They were going out with raw code in the
> subject instead of the post title; the template was rebuilt for Odoo 17.
over:
> - fix: migrate mail.template body_html ${} → qweb t-out
Keep the technical detail for commit messages and the CLAUDE.md failure table; the CHANGELOG is
the human-readable record. Group entries under **Fixed / Added / Changed / Testing &
Infrastructure / Documentation** as applicable.
### Rules
- One tag per version; tags are immutable. If a release is wrong, ship the next PATCH — never
move a tag.
- Tag on the release branch **after** the work is merged/finalised, so the tag points at the
commit that actually ships.
- The Odoo manifest version must always be `17.0.` + the `VERSION` value. The `-u` upgrade path
relies on the manifest version increasing, so bump before deploying schema/data changes.
---
## 16. Phased Adoption Triggers
**Add Phase 2 (BDD, factories, coverage gates) when ≥ 2:**
- Stakeholders ask "how do we know X works?"
- Tests break on demo-data changes, not code changes
- Production N+1 regression caught post-deploy
- Coverage < 60% on touched files
**Add Phase 3 (Runboat) when ≥ 2:**
- Stakeholder asks "can I see it before merge?"
- Staging environment contention costs developer time
- Bugs escape CI that a human click-through would catch
**Add Phase 4 (Playwright, migration testing) when ≥ 2:**
- Production incident that automated testing would have caught
- Odoo upgrade requires emergency rollback
- Team says "I don't trust the test suite"