itsulu-blog-publisher/CLAUDE.md
2026-05-30 19:01:09 -04:00

45 KiB
Raw Permalink Blame History

name description
odoo-testing-framework 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)

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)

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

@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:

pytest addons/ -m sale           # Only sale-tagged tests
pytest addons/ -m "not slow"     # Exclude slow tests

3.4 Common assertions

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.

# 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:

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.

# 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:

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

# 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

# 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

# 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

# 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:

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:

# .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)

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

# 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)

# 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

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

# .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

# .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.

# 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:

# 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:

# 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):

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:

# 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

Odoo version branches (one series per branch — the odoo/odoo model)

The addon targets multiple Odoo Community releases, one per long-lived branch, exactly like odoo/odoo (19.0, 18.0, …, 14.0). Initially we ship:

Branch Odoo series Why
19.0 Odoo Community 19.0 Latest stable — primary supported line
14.0 Odoo Community 14.0 The version ITSulu currently runs in production

Rules:

  • Each Odoo series lives on its own branch. Never mix two Odoo versions in one branch. The branch name is the series (19.0, 14.0).
  • The product version (VERSION, e.g. 0.5.0) is shared across series — the same feature set, ported per Odoo release. The Odoo manifest version is <series>.<product>19.0.0.5.0 on 19.0, 14.0.0.5.0 on 14.0.
  • bump-version.sh resolves the series from .odoo-series (a one-line file at repo root), else the branch name, else 17.0. The Dockerfile base image (FROM odoo:<series>) and the manifest prefix must match the branch.
  • Release tags are namespaced by series so one product version ships on several branches: 19.0-v0.5.0, 14.0-v0.5.0. (The pre-multi-version release v0.4.8 predates this scheme.)
  • A feature is "done" only when it lands on every supported series branch. Port a fix to each branch (cherry-pick or re-implement) — Odoo APIs differ across 14 → 19, so a clean cherry-pick is the exception, not the rule.
  • Odoo-version-specific gotchas (mail.template syntax, blog.post fields, rendering APIs) are in §11.5 and the §12 failure table. Always confirm which series you are on before fixing.

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 (example on branch 19.0) Notes
VERSION 0.5.0 Source of truth for the product version — plain text, one line. Same across series branches
.odoo-series 19.0 The Odoo series this branch targets (one line). Drives manifest prefix + tag namespace
addons/itsulu_blog_publisher/__manifest__.py 'version': '19.0.0.5.0' Odoo manifest = <series>.<product>
Dockerfile FROM odoo:19.0 Base image must match the series
README.md **Version:** 0.5.0 Header line + status footer
CHANGELOG.md ## v0.5.0 — <date> Release notes section per product version
git tag 19.0-v0.5.0 Annotated tag, namespaced by series; message = the CHANGELOG section

Never hand-edit the version in multiple files. Run bump-version.sh on the target series branch — it reads VERSION + the resolved series and updates VERSION, the manifest (<series>.<MAJOR>.<MINOR>.<PATCH>), and README together.

Release process (every version change)

# Run ON the target series branch (e.g. git checkout 19.0).

# 1. Bump the version (updates VERSION, manifest with the branch's series, 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 — names the tag <series>-vX.Y.Z, message from the CHANGELOG section
scripts/bump-version.sh tag

# 5. Push branch and the series-namespaced tag (e.g. 19.0-v0.5.0)
git push && git push origin "$(cat .odoo-series 2>/dev/null || echo 17.0)-v$(cat VERSION)"

# 6. Port the same change to the other series branches (14.0, …) and tag each there.

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 <series> + product version; tags are immutable. If a release is wrong, ship the next PATCH — never move a tag. (v0.4.8 is the pre-multi-version exception.)
  • Tag on the series branch after the work is merged/finalised, so the tag points at the commit that actually ships.
  • The Odoo manifest version must always be <branch series>. + the VERSION value (e.g. 19.0.0.5.0). The -u upgrade path relies on the manifest version increasing, so bump before deploying schema/data changes.
  • A change is not "released" until it is on every supported series branch with its own tag.

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

17. Infrastructure (ITSulu Cluster)

Deployment topology

Service Namespace URL
Blog Publisher (Odoo 19) blog-publisher https://blog.itsulu.com
Runboat CI runboat https://runboat.itsulu.com
Forgejo forgejo https://git.itsulu.com
Runboat build namespace itsulu-testing *.runboat.itsulu.com

Odoo 19 porting notes (learned 2026-05-30)

These fields/tags were removed in Odoo 17+ and will cause ParseError on first install if present:

Old (≤16) New (17+) File type
<tree> <list> view XML
view_mode="tree,form" view_mode="list,form" action XML
<group expand="0" string="Group By"> in <search> <separator/> then bare <filter> search view XML
target="inline" on ir.actions.act_window target="current" action XML
numbercall field on ir.cron removed — omit entirely data XML

Docker image build

The 19.0 Docker image is built manually from this repo root and pushed directly:

docker build -t registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:19.0 .
docker push registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:19.0

CI build_image job only runs on main and merge_requests. Branch 19.0 is built locally until a main branch is established or CI is updated.

Secrets management

All secrets are in Vault (itsulu/* path), synced by ESO ClusterSecretStore/css-vault. Namespaces must be listed in kubernetes/eso/css.yaml spec.conditions[0].namespaces to use ESO.

The gitlab/dockerconfigjson Vault key must store dockerconfigjson as the property name, and the ESO ExternalSecret must use secretKey: .dockerconfigjson + property: dockerconfigjson (NOT the template.data approach which adds a broken {"dockerconfigjson":"..."} wrapper).

Runboat integration

The runboat_tag CI stage (.gitlab-ci.yml) fires on 19.0 and main after build_image:

  1. Tags the commit-SHA image with the branch slug (registry…:19-0)
  2. POSTs to $RUNBOAT_BASE_URL/api/v1/builds to trigger a live test instance

Runboat is a fork of sbidoul/runboat at gitlab.com/itsulu-odoo/runboat with two added env vars: RUNBOAT_FORGE_API_BASE_URL and RUNBOAT_FORGE_WEB_BASE_URL (defaults to GitHub; set to https://git.itsulu.com/api/v1 and https://git.itsulu.com for Forgejo).

The upstream PR branch is forgejo-configurable-base-url on gitlab.com/itsulu-odoo/runboat.

  • Team says "I don't trust the test suite"