Files
StarPunk/docs/reports/test-failure-analysis-deleted-at-attribute.md
Phil Skentelbery 0cca8169ce feat: Implement Phase 4 Web Interface with bugfixes (v0.5.2)
## Phase 4: Web Interface Implementation

Implemented complete web interface with public and admin routes,
templates, CSS, and development authentication.

### Core Features

**Public Routes**:
- Homepage with recent published notes
- Note permalinks with microformats2
- Server-side rendering (Jinja2)

**Admin Routes**:
- Login via IndieLogin
- Dashboard with note management
- Create, edit, delete notes
- Protected with @require_auth decorator

**Development Authentication**:
- Dev login bypass for local testing (DEV_MODE only)
- Security safeguards per ADR-011
- Returns 404 when disabled

**Templates & Frontend**:
- Base layouts (public + admin)
- 8 HTML templates with microformats2
- Custom responsive CSS (114 lines)
- Error pages (404, 500)

### Bugfixes (v0.5.1 → v0.5.2)

1. **Cookie collision fix (v0.5.1)**:
   - Renamed auth cookie from "session" to "starpunk_session"
   - Fixed redirect loop between dev login and admin dashboard
   - Flask's session cookie no longer conflicts with auth

2. **HTTP 404 error handling (v0.5.1)**:
   - Update route now returns 404 for nonexistent notes
   - Delete route now returns 404 for nonexistent notes
   - Follows ADR-012 HTTP Error Handling Policy
   - Pattern consistency across all admin routes

3. **Note model enhancement (v0.5.2)**:
   - Exposed deleted_at field from database schema
   - Enables soft deletion verification in tests
   - Follows ADR-013 transparency principle

### Architecture

**New ADRs**:
- ADR-011: Development Authentication Mechanism
- ADR-012: HTTP Error Handling Policy
- ADR-013: Expose deleted_at Field in Note Model

**Standards Compliance**:
- Uses uv for Python environment
- Black formatted, Flake8 clean
- Follows git branching strategy
- Version incremented per versioning strategy

### Test Results

- 405/406 tests passing (99.75%)
- 87% code coverage
- All security tests passing
- Manual testing confirmed working

### Documentation

- Complete implementation reports in docs/reports/
- Architecture reviews in docs/reviews/
- Design documents in docs/design/
- CHANGELOG updated for v0.5.2

### Files Changed

**New Modules**:
- starpunk/dev_auth.py
- starpunk/routes/ (public, admin, auth, dev_auth)

**Templates**: 10 files (base, pages, admin, errors)
**Static**: CSS and optional JavaScript
**Tests**: 4 test files for routes and templates
**Docs**: 20+ architectural and implementation documents

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 23:01:53 -07:00

489 lines
15 KiB
Markdown

# Test Failure Analysis: Missing `deleted_at` Attribute on Note Model
**Date**: 2025-11-18
**Status**: Issue Identified - Architectural Guidance Provided
**Test**: `test_delete_without_confirmation_cancels` (tests/test_routes_admin.py:441)
**Error**: `AttributeError: 'Note' object has no attribute 'deleted_at'`
---
## Executive Summary
A test is failing because it expects the `Note` model to expose a `deleted_at` attribute, but this field is **not included in the Note dataclass definition** despite being present in the database schema. This is a **model-schema mismatch** issue.
**Root Cause**: The `deleted_at` column exists in the database (`starpunk/database.py:20`) but is not mapped to the `Note` dataclass (`starpunk/models.py:44-121`).
**Impact**:
- Test suite failure prevents CI/CD pipeline success
- Soft deletion feature is partially implemented but not fully exposed through the model layer
- Code that attempts to check deletion status will fail at runtime
**Recommended Fix**: Add `deleted_at` field to the Note dataclass definition
---
## Analysis
### 1. Database Schema Review
**File**: `starpunk/database.py:11-27`
The database schema **includes** a `deleted_at` column:
```sql
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
file_path TEXT UNIQUE NOT NULL,
published BOOLEAN DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP, -- ← THIS FIELD EXISTS
content_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
```
**Key Findings**:
- `deleted_at` is defined as a nullable TIMESTAMP column
- An index exists on `deleted_at` for query performance
- The schema supports soft deletion architecture
### 2. Note Model Review
**File**: `starpunk/models.py:44-121`
The Note dataclass **does not include** `deleted_at`:
```python
@dataclass(frozen=True)
class Note:
"""Represents a note/post"""
# Core fields from database
id: int
slug: str
file_path: str
published: bool
created_at: datetime
updated_at: datetime
# Internal fields (not from database)
_data_dir: Path = field(repr=False, compare=False)
# Optional fields
content_hash: Optional[str] = None
# ← MISSING: deleted_at field
```
**Key Findings**:
- The model lists 6 "core fields from database" but only includes 6 of the 7 columns
- `deleted_at` is completely absent from the dataclass definition
- The `from_row()` class method (line 123-162) does not extract `deleted_at` from database rows
### 3. Notes Module Review
**File**: `starpunk/notes.py`
The notes module **uses** `deleted_at` in queries but **never exposes** it:
```python
# Line 358-364: get_note() filters by deleted_at
row = db.execute(
"SELECT * FROM notes WHERE slug = ? AND deleted_at IS NULL", (slug,)
).fetchone()
# Line 494: list_notes() filters by deleted_at
query = "SELECT * FROM notes WHERE deleted_at IS NULL"
# Line 800-804: delete_note() sets deleted_at for soft deletes
db.execute(
"UPDATE notes SET deleted_at = ? WHERE id = ?",
(deleted_at, existing_note.id),
)
```
**Key Findings**:
- The application logic **knows about** `deleted_at`
- Queries correctly filter out soft-deleted notes (`deleted_at IS NULL`)
- Soft deletion is implemented by setting `deleted_at` to current timestamp
- However, the model layer **never reads this value back** from the database
- This creates a **semantic gap**: the database has the data, but the model can't access it
### 4. Failing Test Review
**File**: `tests/test_routes_admin.py:441`
The test expects to verify deletion status:
```python
def test_delete_without_confirmation_cancels(self, authenticated_client, sample_notes):
"""Test that delete without confirmation cancels operation"""
# ... test logic ...
# Verify note was NOT deleted (still exists)
with authenticated_client.application.app_context():
from starpunk.notes import get_note
note = get_note(id=note_id)
assert note is not None # Note should still exist
assert note.deleted_at is None # NOT soft-deleted ← FAILS HERE
```
**Key Findings**:
- Test wants to **explicitly verify** that a note is not soft-deleted
- This is a reasonable test - it validates business logic
- The test assumes `deleted_at` is accessible on the Note model
- Without the field, the test cannot verify soft-deletion status
---
## Architectural Assessment
### Why This Is a Problem
1. **Model-Schema Mismatch**: The fundamental rule of data models is that they should accurately represent the database schema. Currently, `Note` is incomplete.
2. **Information Hiding**: The application knows about soft deletion (it uses it), but the model layer hides this information from consumers. This violates the **principle of least surprise**.
3. **Testing Limitation**: Tests cannot verify soft-deletion behavior without accessing the field. This creates a testing blind spot.
4. **Future Maintenance**: Any code that needs to check deletion status (admin UI, API responses, debugging tools) will face the same issue.
### Why `deleted_at` Was Omitted
Looking at the git history and design patterns, I can infer the reasoning:
1. **Query-Level Filtering**: The developer chose to filter soft-deleted notes at the **query level** (`WHERE deleted_at IS NULL`), making `deleted_at` invisible to consumers.
2. **Encapsulation**: This follows a pattern of "consumers shouldn't need to know about deletion mechanics" - they just get active notes.
3. **Simplicity**: By excluding `deleted_at`, the model is simpler and consumers don't need to remember to filter it.
This is a **defensible design choice** for application code, but it creates problems for:
- Testing
- Admin interfaces (where you might want to show soft-deleted items)
- Debugging
- Data export/backup tools
### Design Principles at Stake
1. **Transparency vs Encapsulation**:
- Encapsulation says: "Hide implementation details (soft deletion) from consumers"
- Transparency says: "Expose database state accurately"
- **Verdict**: For data models, transparency should win
2. **Data Integrity**:
- The model should be a **faithful representation** of the database
- Hiding fields creates a semantic mismatch
- **Verdict**: Add the field
3. **Testability**:
- Tests need to verify deletion behavior
- Current design makes this impossible
- **Verdict**: Add the field
---
## Architectural Decision
**Decision**: Add `deleted_at: Optional[datetime]` to the Note dataclass
**Rationale**:
1. **Principle of Least Surprise**: If a database column exists, developers expect to access it through the model
2. **Testability**: Tests must be able to verify soft-deletion state
3. **Transparency**: Data models should accurately reflect database schema
4. **Future Flexibility**: Admin UIs, backup tools, and debugging features will need this field
5. **Low Complexity Cost**: Adding one optional field is minimal complexity
6. **Backwards Compatibility**: The field is optional (nullable), so existing code won't break
**Trade-offs Accepted**:
- **Loss of Encapsulation**: Consumers now see "deleted_at" and must understand soft deletion
- **Mitigation**: Document the field clearly; provide helper properties if needed
- **Slight Complexity Increase**: Model has one more field
- **Impact**: Minimal - one line of code
---
## Implementation Plan
### Changes Required
**File**: `starpunk/models.py`
1. Add `deleted_at` field to Note dataclass (line ~109):
```python
@dataclass(frozen=True)
class Note:
"""Represents a note/post"""
# Core fields from database
id: int
slug: str
file_path: str
published: bool
created_at: datetime
updated_at: datetime
deleted_at: Optional[datetime] = None # ← ADD THIS
# Internal fields (not from database)
_data_dir: Path = field(repr=False, compare=False)
# Optional fields
content_hash: Optional[str] = None
```
2. Update `from_row()` class method to extract `deleted_at` (line ~145-162):
```python
@classmethod
def from_row(cls, row: sqlite3.Row | dict[str, Any], data_dir: Path) -> "Note":
# ... existing code ...
# Convert timestamps if they are strings
created_at = data["created_at"]
if isinstance(created_at, str):
created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
updated_at = data["updated_at"]
if isinstance(updated_at, str):
updated_at = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
# ← ADD THIS BLOCK
deleted_at = data.get("deleted_at")
if deleted_at and isinstance(deleted_at, str):
deleted_at = datetime.fromisoformat(deleted_at.replace("Z", "+00:00"))
return cls(
id=data["id"],
slug=data["slug"],
file_path=data["file_path"],
published=bool(data["published"]),
created_at=created_at,
updated_at=updated_at,
deleted_at=deleted_at, # ← ADD THIS
_data_dir=data_dir,
content_hash=data.get("content_hash"),
)
```
3. (Optional) Update `to_dict()` method to include `deleted_at` when serializing (line ~354-406):
```python
def to_dict(
self, include_content: bool = False, include_html: bool = False
) -> dict[str, Any]:
data = {
"id": self.id,
"slug": self.slug,
"title": self.title,
"published": self.published,
"created_at": self.created_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
"updated_at": self.updated_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
"permalink": self.permalink,
"excerpt": self.excerpt,
}
# ← ADD THIS BLOCK (optional, for API consistency)
if self.deleted_at is not None:
data["deleted_at"] = self.deleted_at.strftime("%Y-%m-%dT%H:%M:%SZ")
# ... rest of method ...
```
4. Update docstring to document the field (line ~44-100):
```python
@dataclass(frozen=True)
class Note:
"""
Represents a note/post
Attributes:
id: Database ID (primary key)
slug: URL-safe slug (unique)
file_path: Path to markdown file (relative to data directory)
published: Whether note is published (visible publicly)
created_at: Creation timestamp (UTC)
updated_at: Last update timestamp (UTC)
deleted_at: Soft deletion timestamp (UTC, None if not deleted) # ← ADD THIS
content_hash: SHA-256 hash of content (for integrity checking)
# ... rest of docstring ...
"""
```
### Testing Strategy
**Unit Tests**:
1. Verify `Note.from_row()` correctly parses `deleted_at` from database rows
2. Verify `deleted_at` defaults to `None` for active notes
3. Verify `deleted_at` is set to timestamp for soft-deleted notes
4. Verify `to_dict()` includes `deleted_at` when present
**Integration Tests**:
1. The failing test should pass: `test_delete_without_confirmation_cancels`
2. Verify soft-deleted notes have `deleted_at` set after `delete_note(soft=True)`
3. Verify `get_note()` returns `None` for soft-deleted notes (existing behavior)
4. Verify hard-deleted notes are removed entirely (existing behavior)
**Regression Tests**:
1. Run full test suite to ensure no existing tests break
2. Verify `list_notes()` still excludes soft-deleted notes
3. Verify `get_note()` still excludes soft-deleted notes
### Acceptance Criteria
- [ ] `deleted_at` field added to Note dataclass
- [ ] `from_row()` extracts `deleted_at` from database rows
- [ ] `from_row()` handles `deleted_at` as string (ISO format)
- [ ] `from_row()` handles `deleted_at` as None (active notes)
- [ ] Docstring updated to document `deleted_at`
- [ ] Test `test_delete_without_confirmation_cancels` passes
- [ ] Full test suite passes
- [ ] No regression in existing functionality
---
## Alternative Approaches Considered
### Alternative 1: Update Test to Remove `deleted_at` Check
**Approach**: Change the test to not check `deleted_at`
```python
# Instead of:
assert note.deleted_at is None
# Use:
# (No check - just verify note exists)
assert note is not None
```
**Pros**:
- Minimal code change (one line)
- Maintains current encapsulation
**Cons**:
- **Weakens test coverage**: Can't verify note is truly not soft-deleted
- **Doesn't solve root problem**: Future code will hit the same issue
- **Violates test intent**: Test specifically wants to verify deletion status
**Verdict**: REJECTED - This is a band-aid, not a fix
### Alternative 2: Add Helper Property Instead of Raw Field
**Approach**: Keep `deleted_at` hidden, add `is_deleted` property
```python
@dataclass(frozen=True)
class Note:
# ... existing fields ...
_deleted_at: Optional[datetime] = field(default=None, repr=False)
@property
def is_deleted(self) -> bool:
"""Check if note is soft-deleted"""
return self._deleted_at is not None
```
**Pros**:
- Provides boolean flag for deletion status
- Hides timestamp implementation detail
- Encapsulates deletion logic
**Cons**:
- **Information loss**: Tests/admin UIs can't see when note was deleted
- **Inconsistent with other models**: Session, Token, AuthState all expose timestamps
- **More complex**: Two fields instead of one
- **Harder to serialize**: Can't include deletion timestamp in API responses
**Verdict**: REJECTED - Adds complexity without clear benefit
### Alternative 3: Create Separate SoftDeletedNote Model
**Approach**: Use different model classes for active vs deleted notes
**Pros**:
- Type safety: Can't accidentally mix active and deleted notes
- Clear separation of concerns
**Cons**:
- **Massive complexity increase**: Two model classes, complex query logic
- **Violates simplicity principle**: Way over-engineered for the problem
- **Breaks existing code**: Would require rewriting note operations
**Verdict**: REJECTED - Far too complex for V1
---
## Risk Assessment
**Risk Level**: LOW
**Implementation Risks**:
- **Breaking Changes**: None - field is optional and nullable
- **Performance Impact**: None - no additional queries or processing
- **Security Impact**: None - field is read-only from model perspective
**Migration Risks**:
- **Database Migration**: None needed - column already exists
- **Data Backfill**: None needed - existing notes have NULL by default
- **API Compatibility**: Potential change if `to_dict()` includes `deleted_at`
- **Mitigation**: Make inclusion optional or conditional
---
## Summary for Developer
**What to do**:
1. Add `deleted_at: Optional[datetime] = None` to Note dataclass
2. Update `from_row()` to extract and parse `deleted_at`
3. Update docstring to document the field
4. Run test suite to verify fix
**Why**:
- Database has `deleted_at` column but model doesn't expose it
- Tests need to verify soft-deletion status
- Models should accurately reflect database schema
**Complexity**: LOW (3 lines of code change)
**Time Estimate**: 5 minutes implementation + 2 minutes testing
**Files to modify**:
- `starpunk/models.py` (primary change)
- No migration needed (database already has column)
- No test changes needed (test is already correct)
---
## References
- Database Schema: `/home/phil/Projects/starpunk/starpunk/database.py:11-27`
- Note Model: `/home/phil/Projects/starpunk/starpunk/models.py:44-440`
- Notes Module: `/home/phil/Projects/starpunk/starpunk/notes.py:685-849`
- Failing Test: `/home/phil/Projects/starpunk/tests/test_routes_admin.py:435-441`
---
**Next Steps**:
1. Review this analysis with development team
2. Get approval for recommended fix
3. Implement changes to `starpunk/models.py`
4. Verify test passes
5. Document decision in ADR if desired