# Class-Based Check Pipeline Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Refactor formguard's check pipeline from functions to classes so that each check owns its form fields, widgets, media, settings, and test data -- enabling checks like reCAPTCHA/Turnstile that need their own widgets.

**Architecture:** `BaseCheck` defines the interface (`get_fields`, `get_media`, `check`, `test_data`, `get_setting`, `fail_open`). Three built-in checks (`FieldTrapCheck`, `TokenCheck`, `JsChallengeCheck`) are fully self-contained with no cross-check dependencies. `GuardedFormMixin` becomes a pure aggregator. Views and decorators unchanged.

**Tech Stack:** Django 4.2+, Python 3.10+

**Key design decisions:**
- JsChallengeCheck uses its own random nonce (`secrets.token_hex`), NOT the signed token from TokenCheck. Zero shared state between checks.
- Timing and signature checks merge into `TokenCheck` (both validate the same `fg_token` field).
- Each check declares a `settings_prefix` and `defaults` dict. `self.get_setting('X')` reads `FORMGUARD_{PREFIX}_{X}` from Django settings, falling back to the check's defaults.
- `fail_open = True` by default. Checks can set `fail_open = False` for fail-closed behavior on exceptions.
- Check instances cached on the form (`self._checks`) to avoid double instantiation.

---

## File Structure

```
formguard/
  checks.py          -- REWRITE: BaseCheck class + 3 built-in check classes + get_checks() + run_checks()
  conf.py            -- MODIFY: update CHECKS default, remove TOKEN/JS field name constants, keep global get_setting()
  forms.py           -- REWRITE: GuardedFormMixin becomes pure aggregator over check instances
  test.py            -- REWRITE: guard_data() aggregates from check test_data() methods
  widgets.py         -- UNCHANGED
  views.py           -- UNCHANGED
  decorators.py      -- UNCHANGED
  utils.py           -- UNCHANGED
  signals.py         -- UNCHANGED
  __init__.py        -- MODIFY: version bump to 0.2.0
  static/formguard/
    formguard.js     -- MODIFY: find [data-fg-nonce] instead of [data-fg-token]
  tests/
    test_checks.py   -- REWRITE: test class-based checks
    test_forms.py    -- REWRITE: test aggregation, collision detection
    test_helpers.py  -- REWRITE: test guard_data() aggregation
    test_conf.py     -- MODIFY: update default CHECKS assertion
    test_views.py    -- MINOR: update imports if needed, guard_data() handles the rest
    test_decorators.py -- MINOR: same
    test_signals.py  -- UNCHANGED
    test_widgets.py  -- UNCHANGED
    views.py         -- UNCHANGED (test views use GuardedFormMixin which auto-adapts)
```

---

### Task 1: BaseCheck class and get_checks/run_checks infrastructure

**Files:**
- Rewrite: `formguard/checks.py`
- Modify: `formguard/conf.py`
- Test: `formguard/tests/test_checks.py`

This task builds the foundation: `BaseCheck` with the full interface, `get_checks()` to instantiate from settings, and `run_checks()` with fail_open/fail_closed support. No built-in check implementations yet -- just the infrastructure and tests against stub checks.

- [ ] **Step 1: Write BaseCheck and infrastructure tests**

```python
# formguard/tests/test_checks.py
import secrets
from unittest.mock import MagicMock

from django.core.exceptions import ImproperlyConfigured
from django.forms import CharField, HiddenInput, Media
from django.test import SimpleTestCase, override_settings

from formguard.checks import BaseCheck, get_checks, run_checks


def _make_request():
    request = MagicMock()
    request.META = {'REMOTE_ADDR': '127.0.0.1'}
    return request


class BaseCheckInterfaceTests(SimpleTestCase):
    # get_fields returns empty dict by default
    def test_default_get_fields(self):
        check = BaseCheck()
        assert check.get_fields() == {}

    # get_media returns empty Media by default
    def test_default_get_media(self):
        check = BaseCheck()
        media = check.get_media()
        assert isinstance(media, Media)

    # check raises NotImplementedError by default
    def test_check_raises_not_implemented(self):
        check = BaseCheck()
        with self.assertRaises(NotImplementedError):
            check.check(_make_request(), MagicMock())

    # test_data returns empty dict by default
    def test_default_test_data(self):
        check = BaseCheck()
        assert check.test_data() == {}

    # fail_open defaults to True
    def test_fail_open_default(self):
        check = BaseCheck()
        assert check.fail_open is True


class CheckSettingsTests(SimpleTestCase):
    # get_setting reads from Django settings with prefix
    @override_settings(FORMGUARD_MYCHECK_SOME_KEY='custom_value')
    def test_reads_django_setting_with_prefix(self):
        class MyCheck(BaseCheck):
            settings_prefix = 'MYCHECK'
            defaults = {'SOME_KEY': 'default_value'}

            def check(self, request, form):
                return False

        check = MyCheck()
        assert check.get_setting('SOME_KEY') == 'custom_value'

    # get_setting falls back to check defaults
    def test_falls_back_to_defaults(self):
        class MyCheck(BaseCheck):
            settings_prefix = 'MYCHECK'
            defaults = {'SOME_KEY': 'default_value'}

            def check(self, request, form):
                return False

        check = MyCheck()
        assert check.get_setting('SOME_KEY') == 'default_value'

    # get_setting raises ImproperlyConfigured for unknown key
    def test_unknown_key_raises(self):
        class MyCheck(BaseCheck):
            settings_prefix = 'MYCHECK'
            defaults = {}

            def check(self, request, form):
                return False

        check = MyCheck()
        with self.assertRaises(ImproperlyConfigured):
            check.get_setting('NONEXISTENT')


class GetChecksTests(SimpleTestCase):
    # loads and instantiates check classes from settings
    @override_settings(FORMGUARD_CHECKS=['formguard.tests.test_checks.StubCheck'])
    def test_loads_checks(self):
        checks = get_checks()
        assert len(checks) == 1
        assert isinstance(checks[0], BaseCheck)

    # raises ImproperlyConfigured for non-importable path
    @override_settings(FORMGUARD_CHECKS=['formguard.checks.NonExistent'])
    def test_bad_import_raises(self):
        with self.assertRaises(ImproperlyConfigured):
            get_checks()

    # raises ImproperlyConfigured for non-BaseCheck class
    @override_settings(FORMGUARD_CHECKS=['formguard.tests.test_checks.NotACheck'])
    def test_non_check_raises(self):
        with self.assertRaises(ImproperlyConfigured):
            get_checks()


class RunChecksTests(SimpleTestCase):
    # all checks pass returns empty list
    @override_settings(FORMGUARD_CHECKS=['formguard.tests.test_checks.StubCheck'])
    def test_all_pass(self):
        form = MagicMock()
        form._checks = get_checks()
        reasons = run_checks(_make_request(), form)
        assert reasons == []

    # triggered check returns reason in list
    @override_settings(FORMGUARD_CHECKS=['formguard.tests.test_checks.AlwaysTriggersCheck'])
    def test_triggered_check(self):
        form = MagicMock()
        form._checks = get_checks()
        reasons = run_checks(_make_request(), form)
        assert 'always triggers' in reasons

    # fail_open check: exception is logged and skipped
    @override_settings(FORMGUARD_CHECKS=['formguard.tests.test_checks.CrashingCheck'])
    def test_fail_open_skips(self):
        form = MagicMock()
        form._checks = get_checks()
        reasons = run_checks(_make_request(), form)
        assert reasons == []

    # fail_closed check: exception is treated as triggered
    @override_settings(FORMGUARD_CHECKS=['formguard.tests.test_checks.FailClosedCrashingCheck'])
    def test_fail_closed_triggers(self):
        form = MagicMock()
        form._checks = get_checks()
        reasons = run_checks(_make_request(), form)
        assert len(reasons) == 1


# --- Stub checks for testing infrastructure ---

class StubCheck(BaseCheck):
    def check(self, request, form):
        return False


class AlwaysTriggersCheck(BaseCheck):
    def check(self, request, form):
        return 'always triggers'


class CrashingCheck(BaseCheck):
    def check(self, request, form):
        raise RuntimeError('boom')


class FailClosedCrashingCheck(BaseCheck):
    fail_open = False

    def check(self, request, form):
        raise RuntimeError('boom')


class NotACheck:
    pass
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd c:/Users/Luke/Projects/django-formguard && uv run pytest formguard/tests/test_checks.py -v`
Expected: ImportError or AttributeError (BaseCheck, get_checks don't exist yet)

- [ ] **Step 3: Implement BaseCheck, get_checks, run_checks**

```python
# formguard/checks.py
import logging

from django.conf import settings as django_settings
from django.core.exceptions import ImproperlyConfigured
from django.forms import Media
from django.utils.module_loading import import_string

from formguard.conf import get_setting

logger = logging.getLogger('formguard')


class BaseCheck:
    """Base class for all formguard checks."""

    settings_prefix = ''
    defaults = {}
    fail_open = True

    def get_fields(self):
        """Return fields this check needs added to the form."""
        return {}

    def get_media(self):
        """Return additional CSS/JS this check needs beyond widget media."""
        return Media()

    def check(self, request, form):
        """Return a reason string if triggered, or False if clean."""
        raise NotImplementedError

    def test_data(self):
        """Return dict of valid POST data for this check's fields."""
        return {}

    def get_setting(self, name):
        """Read FORMGUARD_{PREFIX}_{name} from Django settings, falling back to self.defaults."""
        if self.settings_prefix:
            setting_name = f'FORMGUARD_{self.settings_prefix}_{name}'
        else:
            setting_name = f'FORMGUARD_{name}'

        value = getattr(django_settings, setting_name, None)
        if value is not None:
            return value

        if name in self.defaults:
            return self.defaults[name]

        raise ImproperlyConfigured(
            f'{self.__class__.__name__} requires setting {setting_name} '
            f'(no default provided).'
        )


def get_checks():
    """Import and instantiate all configured check classes."""
    check_paths = get_setting('CHECKS')
    checks = []

    for path in check_paths:
        try:
            cls = import_string(path)
        except ImportError as e:
            raise ImproperlyConfigured(
                f'Could not import check \'{path}\': {e}'
            ) from e

        if not (isinstance(cls, type) and issubclass(cls, BaseCheck)):
            raise ImproperlyConfigured(
                f'FORMGUARD_CHECKS entry \'{path}\' is not a BaseCheck subclass.'
            )

        checks.append(cls())

    return checks


def run_checks(request, form):
    """Run all checks cached on the form. Return list of triggered reason strings."""
    reasons = []

    for check in form._checks:
        try:
            result = check.check(request, form)
            if result:
                reasons.append(result)
        except Exception:
            if check.fail_open:
                logger.exception(
                    'formguard check %s raised an exception (fail_open), skipping',
                    check.__class__.__name__,
                )
            else:
                logger.exception(
                    'formguard check %s raised an exception (fail_closed), treating as triggered',
                    check.__class__.__name__,
                )
                reasons.append(f'{check.__class__.__name__} error (fail_closed)')

    return reasons
```

- [ ] **Step 4: Update conf.py -- remove internal constants, update CHECKS default**

The `TOKEN_FIELD_NAME`, `JS_FIELD_NAME`, `SIGNING_SALT` constants move into the check classes that use them. The `CHECKS` default points to the new class paths. Keep `get_setting()` for global settings (`SUCCESS_MESSAGE`). Remove check-specific defaults (`FIELD_NAME`, `MIN_SECONDS`, `MAX_SECONDS`) since those move to check-scoped settings.

```python
# formguard/conf.py
from django.conf import settings

DEFAULTS = {
    'SUCCESS_MESSAGE': None,
    'CHECKS': [
        'formguard.checks.FieldTrapCheck',
        'formguard.checks.TokenCheck',
        'formguard.checks.JsChallengeCheck',
    ],
}


def get_setting(name):
    return getattr(settings, f'FORMGUARD_{name}', DEFAULTS[name])
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `cd c:/Users/Luke/Projects/django-formguard && uv run pytest formguard/tests/test_checks.py -v`
Expected: All tests in the new test_checks.py pass. Other test files will fail (they still import old functions).

- [ ] **Step 6: Update test_conf.py for new defaults**

```python
# formguard/tests/test_conf.py
from django.test import SimpleTestCase, override_settings

from formguard.conf import get_setting


class GetSettingTests(SimpleTestCase):
    # returns default checks pointing to new class paths
    def test_default_checks(self):
        checks = get_setting('CHECKS')
        assert checks == [
            'formguard.checks.FieldTrapCheck',
            'formguard.checks.TokenCheck',
            'formguard.checks.JsChallengeCheck',
        ]

    def test_default_success_message(self):
        assert get_setting('SUCCESS_MESSAGE') is None

    # user override is respected
    @override_settings(FORMGUARD_SUCCESS_MESSAGE='Thanks!')
    def test_override_setting(self):
        assert get_setting('SUCCESS_MESSAGE') == 'Thanks!'
```

- [ ] **Step 7: Run infrastructure + conf tests**

Run: `cd c:/Users/Luke/Projects/django-formguard && uv run pytest formguard/tests/test_checks.py formguard/tests/test_conf.py -v`
Expected: All pass

- [ ] **Step 8: Commit**

```
feat: add BaseCheck class, get_checks, run_checks infrastructure
```

---

### Task 2: Built-in check classes (FieldTrapCheck, TokenCheck, JsChallengeCheck)

**Files:**
- Modify: `formguard/checks.py` (add three check classes)
- Modify: `formguard/static/formguard/formguard.js` (use fg_nonce instead of fg_token)
- Modify: `formguard/widgets.py` (remove JS from HoneypotWidget Media -- it moves to JsChallengeCheck)
- Test: `formguard/tests/test_checks.py` (add per-check tests)

- [ ] **Step 1: Write FieldTrapCheck tests**

Add to `formguard/tests/test_checks.py`:

```python
from formguard.checks import FieldTrapCheck


class FieldTrapCheckTests(SimpleTestCase):
    # get_fields returns honeypot field
    def test_get_fields_returns_honeypot(self):
        check = FieldTrapCheck()
        fields = check.get_fields()
        assert 'website' in fields
        assert fields['website'].required is False

    # empty field passes
    def test_empty_field_passes(self):
        check = FieldTrapCheck()
        form = MagicMock()
        form.cleaned_data = {'website': ''}
        assert check.check(_make_request(), form) is False

    # filled field triggers
    def test_filled_field_triggers(self):
        check = FieldTrapCheck()
        form = MagicMock()
        form.cleaned_data = {'website': 'http://spam.com'}
        result = check.check(_make_request(), form)
        assert isinstance(result, str)

    # respects custom field name via check-scoped setting
    @override_settings(FORMGUARD_FIELD_TRAP_FIELD_NAME='phone')
    def test_custom_field_name(self):
        check = FieldTrapCheck()
        fields = check.get_fields()
        assert 'phone' in fields
        form = MagicMock()
        form.cleaned_data = {'phone': '555-1234'}
        result = check.check(_make_request(), form)
        assert isinstance(result, str)

    # test_data returns empty honeypot value
    def test_test_data(self):
        check = FieldTrapCheck()
        data = check.test_data()
        assert data['website'] == ''
```

- [ ] **Step 2: Write TokenCheck tests**

Add to `formguard/tests/test_checks.py`:

```python
import time
from unittest.mock import patch

from django.core import signing

from formguard.checks import TokenCheck


class TokenCheckTests(SimpleTestCase):
    # get_fields returns fg_token field
    def test_get_fields_returns_token(self):
        check = TokenCheck()
        fields = check.get_fields()
        assert 'fg_token' in fields

    # valid token passes
    def test_valid_token_passes(self):
        check = TokenCheck()
        token = signing.dumps(time.time() - 10, salt='formguard')
        form = MagicMock()
        form.cleaned_data = {'fg_token': token}
        assert check.check(_make_request(), form) is False

    # fast submission triggers
    def test_fast_submission_triggers(self):
        check = TokenCheck()
        token = signing.dumps(time.time(), salt='formguard')
        form = MagicMock()
        form.cleaned_data = {'fg_token': token}
        result = check.check(_make_request(), form)
        assert isinstance(result, str)

    # tampered token triggers
    def test_tampered_token_triggers(self):
        check = TokenCheck()
        form = MagicMock()
        form.cleaned_data = {'fg_token': 'tampered'}
        result = check.check(_make_request(), form)
        assert isinstance(result, str)

    # missing token triggers
    def test_missing_token_triggers(self):
        check = TokenCheck()
        form = MagicMock()
        form.cleaned_data = {'fg_token': ''}
        result = check.check(_make_request(), form)
        assert isinstance(result, str)

    # expired token triggers
    def test_expired_token_triggers(self):
        check = TokenCheck()
        old_time = time.time() - 7200
        with patch('django.core.signing.time.time', return_value=old_time):
            token = signing.dumps(old_time, salt='formguard')
        form = MagicMock()
        form.cleaned_data = {'fg_token': token}
        result = check.check(_make_request(), form)
        assert isinstance(result, str)

    # test_data returns valid signed token
    def test_test_data(self):
        check = TokenCheck()
        data = check.test_data()
        loaded = signing.loads(data['fg_token'], salt='formguard')
        assert isinstance(loaded, float)
```

- [ ] **Step 3: Write JsChallengeCheck tests**

Add to `formguard/tests/test_checks.py`:

```python
from formguard.checks import JsChallengeCheck


def _js_response(nonce_value):
    return format(sum(ord(c) for c in nonce_value) & 0xFFFF, 'x')


class JsChallengeCheckTests(SimpleTestCase):
    # get_fields returns nonce and js fields
    def test_get_fields_returns_nonce_and_js(self):
        check = JsChallengeCheck()
        fields = check.get_fields()
        assert 'fg_nonce' in fields
        assert 'fg_js' in fields

    # correct JS value passes
    def test_correct_js_passes(self):
        check = JsChallengeCheck()
        nonce = secrets.token_hex(16)
        form = MagicMock()
        form.cleaned_data = {'fg_nonce': nonce, 'fg_js': _js_response(nonce)}
        assert check.check(_make_request(), form) is False

    # empty JS field triggers
    def test_empty_js_triggers(self):
        check = JsChallengeCheck()
        form = MagicMock()
        form.cleaned_data = {'fg_nonce': 'abc', 'fg_js': ''}
        result = check.check(_make_request(), form)
        assert isinstance(result, str)

    # wrong JS value triggers
    def test_wrong_js_triggers(self):
        check = JsChallengeCheck()
        form = MagicMock()
        form.cleaned_data = {'fg_nonce': 'abc', 'fg_js': 'wrongval'}
        result = check.check(_make_request(), form)
        assert isinstance(result, str)

    # missing nonce triggers
    def test_missing_nonce_triggers(self):
        check = JsChallengeCheck()
        form = MagicMock()
        form.cleaned_data = {'fg_nonce': '', 'fg_js': 'something'}
        result = check.check(_make_request(), form)
        assert isinstance(result, str)

    # test_data returns consistent nonce + computed js value
    def test_test_data_is_consistent(self):
        check = JsChallengeCheck()
        data = check.test_data()
        nonce = data['fg_nonce']
        expected = _js_response(nonce)
        assert data['fg_js'] == expected

    # get_media includes JS file
    def test_get_media_includes_js(self):
        check = JsChallengeCheck()
        media = check.get_media()
        assert 'formguard/formguard.js' in str(media)
```

- [ ] **Step 4: Run new check tests to verify they fail**

Run: `cd c:/Users/Luke/Projects/django-formguard && uv run pytest formguard/tests/test_checks.py::FieldTrapCheckTests formguard/tests/test_checks.py::TokenCheckTests formguard/tests/test_checks.py::JsChallengeCheckTests -v`
Expected: ImportError (classes don't exist yet)

- [ ] **Step 5: Implement the three built-in check classes**

Add to `formguard/checks.py` (after BaseCheck, before get_checks):

```python
import secrets
import time

from django.core import signing
from django.forms import CharField, HiddenInput

from formguard.widgets import HoneypotWidget

SIGNING_SALT = 'formguard'


class FieldTrapCheck(BaseCheck):
    """Honeypot field that should remain empty. Bots fill it; humans never see it."""

    settings_prefix = 'FIELD_TRAP'
    defaults = {
        'FIELD_NAME': 'website',
    }

    def get_fields(self):
        field_name = self.get_setting('FIELD_NAME')
        return {
            field_name: CharField(
                required=False,
                widget=HoneypotWidget(label=field_name.title()),
            ),
        }

    def check(self, request, form):
        field_name = self.get_setting('FIELD_NAME')
        if form.cleaned_data.get(field_name, ''):
            return 'honeypot field filled'
        return False

    def test_data(self):
        return {self.get_setting('FIELD_NAME'): ''}


class TokenCheck(BaseCheck):
    """Signed timestamp token. Validates timing (not too fast) and integrity (not tampered)."""

    settings_prefix = 'TOKEN'
    defaults = {
        'MIN_SECONDS': 3,
        'MAX_SECONDS': 3600,
    }

    def get_fields(self):
        return {
            'fg_token': CharField(
                required=False,
                widget=HiddenInput(attrs={'data-fg-token': True}),
                initial=self._make_token,
            ),
        }

    def check(self, request, form):
        min_seconds = self.get_setting('MIN_SECONDS')
        max_seconds = self.get_setting('MAX_SECONDS')
        token_value = form.cleaned_data.get('fg_token', '')

        try:
            loaded = signing.loads(token_value, salt=SIGNING_SALT, max_age=max_seconds)
            if time.time() - loaded < min_seconds:
                return 'submitted too fast'
        except (signing.BadSignature, signing.SignatureExpired, TypeError):
            return 'invalid or expired token'

        return False

    def test_data(self):
        return {'fg_token': self._make_token(age=60)}

    @staticmethod
    def _make_token(age=0):
        return signing.dumps(time.time() - age, salt=SIGNING_SALT)


class JsChallengeCheck(BaseCheck):
    """Verify JavaScript executed by computing a function of a random nonce."""

    def get_fields(self):
        return {
            'fg_nonce': CharField(
                required=False,
                widget=HiddenInput(attrs={'data-fg-nonce': True}),
                initial=self._make_nonce,
            ),
            'fg_js': CharField(
                required=False,
                widget=HiddenInput(attrs={'data-fg-js': True}),
                initial='',
            ),
        }

    def get_media(self):
        return Media(js=('formguard/formguard.js',))

    def check(self, request, form):
        nonce_value = form.cleaned_data.get('fg_nonce', '')
        js_value = form.cleaned_data.get('fg_js', '')

        if not js_value or not nonce_value:
            return 'js challenge failed'

        expected = sum(ord(c) for c in nonce_value) & 0xFFFF
        if js_value != format(expected, 'x'):
            return 'js challenge mismatch'

        return False

    def test_data(self):
        nonce = self._make_nonce()
        js_value = format(sum(ord(c) for c in nonce) & 0xFFFF, 'x')
        return {'fg_nonce': nonce, 'fg_js': js_value}

    @staticmethod
    def _make_nonce():
        return secrets.token_hex(16)
```

- [ ] **Step 6: Update formguard.js to use fg_nonce instead of fg_token**

```javascript
// formguard/static/formguard/formguard.js
document.addEventListener('DOMContentLoaded', function() {
    document.querySelectorAll('[data-fg-js]').forEach(function(field) {
        var form = field.form;
        if (!form) return;
        requestAnimationFrame(function() {
            var nonce = form.querySelector('[data-fg-nonce]');
            if (nonce && nonce.value) {
                var sum = 0;
                for (var i = 0; i < nonce.value.length; i++) {
                    sum = (sum + nonce.value.charCodeAt(i)) & 0xFFFF;
                }
                field.value = sum.toString(16);
            }
        });
    });
});
```

- [ ] **Step 7: Remove JS from HoneypotWidget Media**

The JS file is now declared by `JsChallengeCheck.get_media()`, not the widget. Update `formguard/widgets.py` to only declare CSS:

```python
class Media:
    css = {'all': ('formguard/formguard.css',)}
```

- [ ] **Step 8: Run check tests**

Run: `cd c:/Users/Luke/Projects/django-formguard && uv run pytest formguard/tests/test_checks.py -v`
Expected: All pass

- [ ] **Step 9: Commit**

```
feat: add FieldTrapCheck, TokenCheck, JsChallengeCheck classes
```

---

### Task 3: Refactor GuardedFormMixin to aggregate from checks

**Files:**
- Rewrite: `formguard/forms.py`
- Rewrite: `formguard/tests/test_forms.py`

- [ ] **Step 1: Write new form mixin tests**

```python
# formguard/tests/test_forms.py
from django import forms
from django.core.exceptions import ImproperlyConfigured
from django.test import SimpleTestCase, override_settings

from formguard.checks import BaseCheck, FieldTrapCheck, JsChallengeCheck, TokenCheck
from formguard.forms import GuardedFormMixin
from formguard.widgets import HoneypotWidget


class SampleForm(GuardedFormMixin, forms.Form):
    name = forms.CharField()
    email = forms.EmailField()


class GuardedFormMixinTests(SimpleTestCase):
    # mixin aggregates fields from all configured checks
    def test_aggregates_check_fields(self):
        form = SampleForm()
        assert 'website' in form.fields
        assert 'fg_token' in form.fields
        assert 'fg_nonce' in form.fields
        assert 'fg_js' in form.fields

    # mixin caches check instances on self._checks
    def test_caches_checks(self):
        form = SampleForm()
        assert hasattr(form, '_checks')
        assert len(form._checks) == 3

    # honeypot field uses HoneypotWidget
    def test_honeypot_uses_widget(self):
        form = SampleForm()
        assert isinstance(form.fields['website'].widget, HoneypotWidget)

    # check field colliding with form field raises ImproperlyConfigured
    def test_check_field_collides_with_form_field(self):
        class BadForm(GuardedFormMixin, forms.Form):
            website = forms.CharField()

        with self.assertRaises(ImproperlyConfigured):
            BadForm()

    # two checks contributing same field name raises ImproperlyConfigured
    @override_settings(FORMGUARD_CHECKS=[
        'formguard.tests.test_forms.DuplicateFieldCheckA',
        'formguard.tests.test_forms.DuplicateFieldCheckB',
    ])
    def test_inter_check_field_collision_raises(self):
        with self.assertRaises(ImproperlyConfigured):
            SampleForm()

    # media aggregates widget media and check get_media()
    def test_media_aggregation(self):
        form = SampleForm()
        media_str = str(form.media)
        assert 'formguard/formguard.css' in media_str
        assert 'formguard/formguard.js' in media_str

    # empty CHECKS setting produces form with no guard fields
    @override_settings(FORMGUARD_CHECKS=[])
    def test_empty_checks(self):
        form = SampleForm()
        assert 'website' not in form.fields
        assert 'fg_token' not in form.fields


# Stub checks for collision testing
class DuplicateFieldCheckA(BaseCheck):
    def get_fields(self):
        return {'duplicate_field': forms.CharField()}

    def check(self, request, form):
        return False


class DuplicateFieldCheckB(BaseCheck):
    def get_fields(self):
        return {'duplicate_field': forms.CharField()}

    def check(self, request, form):
        return False
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd c:/Users/Luke/Projects/django-formguard && uv run pytest formguard/tests/test_forms.py -v`
Expected: Failures (old mixin still hardcodes fields)

- [ ] **Step 3: Implement new GuardedFormMixin**

```python
# formguard/forms.py
from django.core.exceptions import ImproperlyConfigured

from formguard.checks import get_checks


class GuardedFormMixin:
    """Aggregate fields and media from all configured checks."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self._checks = get_checks()
        seen_fields = {}

        for check in self._checks:
            for name, field in check.get_fields().items():
                if name in self.fields:
                    raise ImproperlyConfigured(
                        f"Check {check.__class__.__name__} adds field '{name}' "
                        f"which conflicts with an existing form field."
                    )
                if name in seen_fields:
                    raise ImproperlyConfigured(
                        f"Check {check.__class__.__name__} adds field '{name}' "
                        f"which is already added by {seen_fields[name]}."
                    )
                seen_fields[name] = check.__class__.__name__
                self.fields[name] = field

    @property
    def media(self):
        base = super().media
        for check in self._checks:
            base += check.get_media()
        return base
```

- [ ] **Step 4: Run form tests**

Run: `cd c:/Users/Luke/Projects/django-formguard && uv run pytest formguard/tests/test_forms.py -v`
Expected: All pass

- [ ] **Step 5: Commit**

```
feat: refactor GuardedFormMixin to aggregate from check classes
```

---

### Task 4: Refactor test helpers (test.py)

**Files:**
- Rewrite: `formguard/test.py`
- Rewrite: `formguard/tests/test_helpers.py`

- [ ] **Step 1: Write new test helper tests**

```python
# formguard/tests/test_helpers.py
from django.test import SimpleTestCase, override_settings

from formguard.checks import get_checks
from formguard.test import GuardedFormTestMixin


class GuardedFormTestMixinTests(GuardedFormTestMixin, SimpleTestCase):
    # guard_data aggregates test_data from all active checks
    def test_guard_data_has_all_check_fields(self):
        data = self.guard_data()
        assert 'website' in data
        assert 'fg_token' in data
        assert 'fg_nonce' in data
        assert 'fg_js' in data

    # guard_data produces valid data (all checks pass)
    def test_guard_data_passes_all_checks(self):
        from formguard.checks import run_checks
        from unittest.mock import MagicMock

        data = self.guard_data()
        form = MagicMock()
        form.cleaned_data = data
        form._checks = get_checks()
        request = MagicMock()
        request.META = {'REMOTE_ADDR': '127.0.0.1'}
        reasons = run_checks(request, form)
        assert reasons == []

    # overrides are applied on top of generated data
    def test_overrides_applied(self):
        data = self.guard_data(website='http://spam.com')
        assert data['website'] == 'http://spam.com'

    # adapts to custom check configuration
    @override_settings(FORMGUARD_CHECKS=['formguard.checks.FieldTrapCheck'])
    def test_adapts_to_check_config(self):
        data = self.guard_data()
        assert 'website' in data
        assert 'fg_token' not in data
        assert 'fg_nonce' not in data
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd c:/Users/Luke/Projects/django-formguard && uv run pytest formguard/tests/test_helpers.py -v`
Expected: Failures (old guard_data hardcodes fields)

- [ ] **Step 3: Implement new test.py**

```python
# formguard/test.py
from formguard.checks import get_checks


class GuardedFormTestMixin:
    """Mixin for TestCase classes that test guarded forms."""

    def guard_data(self, **overrides):
        """Return a dict with valid guard fields for POST data, aggregated from active checks."""
        data = {}
        for check in get_checks():
            data.update(check.test_data())
        data.update(overrides)
        return data
```

Note: `make_guard_token()` is removed. It was a convenience for the old API. The new API is `TokenCheck().test_data()['fg_token']` if anyone needs just a token, but `guard_data()` is the primary interface.

- [ ] **Step 4: Run test helper tests**

Run: `cd c:/Users/Luke/Projects/django-formguard && uv run pytest formguard/tests/test_helpers.py -v`
Expected: All pass

- [ ] **Step 5: Commit**

```
feat: refactor guard_data to aggregate from check test_data methods
```

---

### Task 5: Update integration tests (views, decorators) and remaining tests

**Files:**
- Modify: `formguard/tests/test_views.py`
- Modify: `formguard/tests/test_decorators.py`
- No changes to: `formguard/tests/test_widgets.py`, `formguard/tests/test_signals.py`

The view and decorator tests use `GuardedFormTestMixin.guard_data()` for clean submissions, which now auto-aggregates. For bot submissions, they manually set specific fields -- those field names haven't changed (`website`, `fg_token`). The main changes needed:

1. Remove `make_guard_token` imports (function removed)
2. Use `TokenCheck().test_data()['fg_token']` or `guard_data()` with overrides where tests need a valid token for a bot scenario

- [ ] **Step 1: Update test_views.py**

```python
# formguard/tests/test_views.py
import logging
from unittest.mock import patch

from django.contrib.messages import get_messages
from django.core import signing
from django.test import TestCase, override_settings

from formguard.checks import TokenCheck
from formguard.signals import guard_triggered
from formguard.test import GuardedFormTestMixin


class GuardedFormViewMixinTests(GuardedFormTestMixin, TestCase):
    def setUp(self):
        self.signal_received = []

        def handler(sender, **kwargs):
            self.signal_received.append(kwargs)

        guard_triggered.connect(handler)
        self.addCleanup(guard_triggered.disconnect, handler)

    # clean submission passes through and signal is NOT emitted
    def test_clean_submission(self):
        data = {'name': 'Alice', **self.guard_data()}
        response = self.client.post('/test-form/', data)
        assert response.status_code == 302
        assert response.url == '/success/'
        assert len(self.signal_received) == 0

    # honeypot field filled returns fake success and emits signal
    def test_honeypot_triggers_fake_success(self):
        data = {
            'name': 'Bot',
            **self.guard_data(website='http://spam.com'),
        }
        response = self.client.post('/test-form/', data)
        assert response.status_code == 302
        assert len(self.signal_received) == 1

    # fast submission returns fake success
    def test_fast_submission_triggers(self):
        now = 1000.0
        token = signing.dumps(now, salt='formguard')
        data = {'name': 'Bot', **self.guard_data(fg_token=token)}
        with patch('formguard.checks.time.time', return_value=now + 1):
            response = self.client.post('/test-form/', data)
        assert response.status_code == 302
        assert len(self.signal_received) == 1

    # bad signature returns fake success
    def test_bad_signature_triggers(self):
        data = {'name': 'Bot', **self.guard_data(fg_token='tampered')}
        response = self.client.post('/test-form/', data)
        assert response.status_code == 302
        assert len(self.signal_received) == 1

    # bot_response includes success message when configured
    @override_settings(FORMGUARD_SUCCESS_MESSAGE='Thanks!')
    def test_bot_response_includes_message(self):
        data = {
            'name': 'Bot',
            **self.guard_data(website='http://spam.com'),
        }
        response = self.client.post('/test-form/', data)
        messages = list(get_messages(response.wsgi_request))
        assert any('Thanks!' in str(m) for m in messages)

    # no message when SUCCESS_MESSAGE is None
    def test_no_message_when_none(self):
        data = {
            'name': 'Bot',
            **self.guard_data(website='http://spam.com'),
        }
        response = self.client.post('/test-form/', data)
        messages = list(get_messages(response.wsgi_request))
        assert len(messages) == 0

    # bot detection is logged with IP address
    def test_bot_logged(self):
        with self.assertLogs('formguard', level=logging.WARNING) as cm:
            data = {
                'name': 'Bot',
                **self.guard_data(website='http://spam.com'),
            }
            self.client.post('/test-form/', data)
        assert any('formguard' in msg.lower() for msg in cm.output)
```

- [ ] **Step 2: Update test_decorators.py**

```python
# formguard/tests/test_decorators.py
from django.test import TestCase

from formguard.signals import guard_triggered
from formguard.test import GuardedFormTestMixin


class GuardFormDecoratorTests(GuardedFormTestMixin, TestCase):
    def setUp(self):
        self.signal_received = []

        def handler(sender, **kwargs):
            self.signal_received.append(kwargs)

        guard_triggered.connect(handler)
        self.addCleanup(guard_triggered.disconnect, handler)

    # GET requests pass through to the wrapped view
    def test_get_passes_through(self):
        response = self.client.get('/test-fbv/')
        assert response.status_code == 200
        assert response.content == b'OK'

    # clean POST passes through with form kwarg
    def test_clean_post_passes(self):
        data = {'name': 'Alice', **self.guard_data()}
        response = self.client.post('/test-fbv/', data)
        assert response.status_code == 200
        assert response.content == b'OK'

    # bot POST returns redirect and emits signal
    def test_bot_post_redirects(self):
        data = {
            'name': 'Bot',
            **self.guard_data(website='http://spam.com'),
        }
        response = self.client.post('/test-fbv/', data)
        assert response.status_code == 302
        assert len(self.signal_received) == 1

    # invalid form passes through to the view with form kwarg
    def test_invalid_form_passes_through(self):
        data = {**self.guard_data()}
        response = self.client.post('/test-fbv/', data)
        assert response.status_code == 200


class GuardFormAnnotateModeTests(GuardedFormTestMixin, TestCase):
    # auto_reject=False annotates request instead of redirecting
    def test_annotate_mode_bot(self):
        data = {
            'name': 'Bot',
            **self.guard_data(website='http://spam.com'),
        }
        response = self.client.post('/test-fbv-annotate/', data)
        assert response.status_code == 200
        assert response.content == b'SPAM'

    # annotate mode passes clean submissions normally
    def test_annotate_mode_clean(self):
        data = {'name': 'Alice', **self.guard_data()}
        response = self.client.post('/test-fbv-annotate/', data)
        assert response.status_code == 200
        assert response.content == b'OK'
```

- [ ] **Step 3: Update test_widgets.py -- remove JS from media assertion**

The `HoneypotWidget.Media` no longer includes JS (moved to `JsChallengeCheck.get_media()`). Update the media test:

```python
# Only change test_media_declaration:
def test_media_declaration(self):
    widget = HoneypotWidget()
    assert 'formguard/formguard.css' in str(widget.media)
```

Remove the `assert 'formguard/formguard.js' in str(widget.media)` line.

- [ ] **Step 4: Run full test suite**

Run: `cd c:/Users/Luke/Projects/django-formguard && uv run pytest formguard/ -v`
Expected: All tests pass

- [ ] **Step 5: Commit**

```
feat: update integration tests for class-based check pipeline
```

---

### Task 6: Version bump, changelog, lint

**Files:**
- Modify: `formguard/__init__.py`
- Modify: `CHANGELOG.md`

- [ ] **Step 1: Bump version to 0.2.0**

In `formguard/__init__.py`, change `__version__ = '0.1.1'` to `__version__ = '0.2.0'`.

- [ ] **Step 2: Update CHANGELOG.md**

```markdown
# Changelog

## 0.2.0

**Breaking:** Check pipeline refactored from functions to classes.

- `BaseCheck` class: checks now declare their own fields (`get_fields`), media (`get_media`), settings (`get_setting`), and test data (`test_data`)
- `FieldTrapCheck`, `TokenCheck`, `JsChallengeCheck` replace the old function-based checks
- `TokenCheck` merges the old `timing` and `signature` checks (both validated the same token)
- `JsChallengeCheck` uses its own random nonce instead of borrowing the signed token
- `GuardedFormMixin` is now a pure aggregator -- fields come from checks, not hardcoded
- Per-check `fail_open` attribute (default `True`): fail-closed checks treat exceptions as triggered
- Check-scoped settings: each check declares a `settings_prefix` and `defaults`
- `guard_data()` auto-aggregates from active checks' `test_data()` methods
- `make_guard_token()` removed (use `TokenCheck().test_data()` instead)
- `FORMGUARD_CHECKS` default now points to class paths, not function paths

**Migration guide:**
- Update `FORMGUARD_CHECKS` in settings:
  - `formguard.checks.field_trap` -> `formguard.checks.FieldTrapCheck`
  - `formguard.checks.timing` -> `formguard.checks.TokenCheck`
  - `formguard.checks.signature` -> (merged into `TokenCheck`)
  - `formguard.checks.js_challenge` -> `formguard.checks.JsChallengeCheck`
- Custom function-based checks must be wrapped in a `BaseCheck` subclass
- `FORMGUARD_FIELD_NAME` -> `FORMGUARD_FIELD_TRAP_FIELD_NAME`
- `FORMGUARD_MIN_SECONDS` -> `FORMGUARD_TOKEN_MIN_SECONDS`
- `FORMGUARD_MAX_SECONDS` -> `FORMGUARD_TOKEN_MAX_SECONDS`
- Replace `make_guard_token()` with `TokenCheck().test_data()['fg_token']`

## Unreleased

- Polish README

## 0.1.1

- Move success message from `is_bot()` to `bot_response()` so calling `is_bot()` has no user-visible side effects

## 0.1.0

Initial release.

- `GuardedFormMixin` for adding guard fields to forms
- `GuardedFormViewMixin` for CBV bot detection
- `@guard_form` decorator for FBV bot detection (auto-reject and annotate modes)
- Built-in checks: `field_trap`, `timing`, `signature`, `js_challenge`
- `guard_triggered` Django signal
- `HoneypotWidget` with CSS hiding and JS support
- `GuardedFormTestMixin` and `make_guard_token()` test helpers
- Pluggable check pipeline via `FORMGUARD_CHECKS` setting
```

- [ ] **Step 3: Run linter**

Run: `cd c:/Users/Luke/Projects/django-formguard && ruff check formguard/ && ruff format formguard/`

- [ ] **Step 4: Run full test suite one final time**

Run: `cd c:/Users/Luke/Projects/django-formguard && uv run pytest formguard/ -v`
Expected: All pass

- [ ] **Step 5: Commit**

```
release: v0.2.0 -- class-based check pipeline
```

---

## Summary

| Task | Description | Files touched |
|------|-------------|---------------|
| 1 | BaseCheck + infrastructure | checks.py, conf.py, test_checks.py, test_conf.py |
| 2 | Three built-in check classes | checks.py, formguard.js, widgets.py, test_checks.py |
| 3 | GuardedFormMixin aggregator | forms.py, test_forms.py |
| 4 | Test helpers refactor | test.py, test_helpers.py |
| 5 | Integration test updates | test_views.py, test_decorators.py, test_widgets.py |
| 6 | Version bump + changelog | __init__.py, CHANGELOG.md |
