feat(slugs): Implement timestamp-based slugs per ADR-062
Replaces content-based slug generation with timestamp format YYYYMMDDHHMMSS. Simplifies slug generation and improves privacy by not exposing note content in URLs. Changes: - Add generate_timestamp_slug() to slug_utils.py - Update notes.py to use timestamp slugs for default generation - Sequential collision suffix (-1, -2) instead of random - Custom slugs via mp-slug continue to work unchanged - 892 tests passing (+18 new timestamp slug tests) Per ADR-062 and v1.5.0 Phase 1 specification. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
310
docs/design/v1.5.0/2025-12-17-phase-1-implementation-report.md
Normal file
310
docs/design/v1.5.0/2025-12-17-phase-1-implementation-report.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Phase 1 Implementation Report: Timestamp-Based Slugs
|
||||
|
||||
**Date**: 2025-12-17
|
||||
**Developer**: StarPunk Fullstack Developer Agent
|
||||
**Phase**: v1.5.0 Phase 1 - Timestamp-Based Slugs
|
||||
**Status**: ✅ Complete
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented timestamp-based slug generation per ADR-062, replacing the content-based slug algorithm with a simpler, privacy-preserving timestamp format. All tests pass (892 total).
|
||||
|
||||
---
|
||||
|
||||
## Changes Implemented
|
||||
|
||||
### 1. New Function: `generate_timestamp_slug()`
|
||||
|
||||
**File**: `/home/phil/Projects/starpunk/starpunk/slug_utils.py`
|
||||
|
||||
Created new function that generates slugs in `YYYYMMDDHHMMSS` format with sequential collision handling:
|
||||
|
||||
```python
|
||||
def generate_timestamp_slug(
|
||||
created_at: datetime = None,
|
||||
existing_slugs: Set[str] = None
|
||||
) -> str:
|
||||
"""Generate a timestamp-based slug with collision handling.
|
||||
|
||||
Per ADR-062: Default format is YYYYMMDDHHMMSS with sequential
|
||||
suffix (-1, -2, etc.) for collisions.
|
||||
"""
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
- Base format: `20251216143052` (14 characters)
|
||||
- First collision: `20251216143052-1`
|
||||
- Second collision: `20251216143052-2`
|
||||
- Defaults to UTC now if no timestamp provided
|
||||
- Handles empty existing_slugs set gracefully
|
||||
|
||||
### 2. Updated Note Creation
|
||||
|
||||
**File**: `/home/phil/Projects/starpunk/starpunk/notes.py` (lines 228-231)
|
||||
|
||||
**Before**:
|
||||
```python
|
||||
else:
|
||||
# Generate base slug from content
|
||||
base_slug = generate_slug(content, created_at)
|
||||
|
||||
# Make unique if collision
|
||||
slug = make_slug_unique(base_slug, existing_slugs)
|
||||
|
||||
# Validate final slug (defensive check)
|
||||
if not validate_slug(slug):
|
||||
raise InvalidNoteDataError("slug", slug, f"Generated slug is invalid: {slug}")
|
||||
```
|
||||
|
||||
**After**:
|
||||
```python
|
||||
else:
|
||||
# Generate timestamp-based slug (ADR-062)
|
||||
from starpunk.slug_utils import generate_timestamp_slug
|
||||
slug = generate_timestamp_slug(created_at, existing_slugs)
|
||||
```
|
||||
|
||||
**Simplification**: Removed 8 lines of code, including:
|
||||
- Content-based slug generation call
|
||||
- Separate uniqueness check
|
||||
- Defensive validation check (timestamp slugs are valid by construction)
|
||||
|
||||
### 3. Updated Tests
|
||||
|
||||
#### Added: `tests/test_timestamp_slugs.py`
|
||||
New comprehensive test file with 18 tests covering:
|
||||
- Basic timestamp format validation
|
||||
- Collision handling with sequential suffixes
|
||||
- Edge cases (midnight, end of day, leap year, single-digit padding)
|
||||
- Integration with note creation
|
||||
- Custom slug compatibility verification
|
||||
|
||||
#### Updated: `tests/test_custom_slugs.py`
|
||||
- Added `TIMESTAMP_SLUG_PATTERN` constant for validation
|
||||
- Updated `test_create_note_without_custom_slug` to use pattern matching
|
||||
- Updated `test_empty_slug_uses_auto_generation` to verify timestamp format
|
||||
- Updated `test_whitespace_only_slug_uses_auto_generation` to accept both old and new timestamp formats
|
||||
|
||||
**Note**: Whitespace custom slugs go through `sanitize_slug()` which uses the older `YYYYMMDD-HHMMSS` format. This is acceptable as it only affects invalid custom slugs, not default generation.
|
||||
|
||||
#### Updated: `tests/test_notes.py`
|
||||
- Updated `test_create_generates_unique_slug` to test timestamp collision handling with fixed timestamps
|
||||
|
||||
### 4. Preserved Legacy Code
|
||||
|
||||
**File**: `/home/phil/Projects/starpunk/starpunk/utils.py`
|
||||
|
||||
The old `generate_slug()` function remains unchanged for backward compatibility and potential future use. This follows the architect's guidance in the response document.
|
||||
|
||||
---
|
||||
|
||||
## Testing Results
|
||||
|
||||
### Test Summary
|
||||
```
|
||||
892 passed, 1 warning in ~6 minutes
|
||||
```
|
||||
|
||||
**Test Count Change**:
|
||||
- Previous: 874 tests
|
||||
- New: 892 tests (+18 from new test_timestamp_slugs.py file)
|
||||
|
||||
### Key Tests Verified
|
||||
|
||||
1. **Timestamp Format**:
|
||||
- ✅ Format matches `YYYYMMDDHHMMSS` exactly
|
||||
- ✅ No hyphen between date and time components
|
||||
- ✅ 14 characters total
|
||||
|
||||
2. **Collision Handling**:
|
||||
- ✅ Base slug gets no suffix: `20251216143052`
|
||||
- ✅ First collision gets `-1`: `20251216143052-1`
|
||||
- ✅ Second collision gets `-2`: `20251216143052-2`
|
||||
- ✅ Sequential suffixes work up to 10+
|
||||
|
||||
3. **Edge Cases**:
|
||||
- ✅ Midnight timestamp: `20250101000000`
|
||||
- ✅ End of day: `20251231235959`
|
||||
- ✅ Leap year: `20240229123045`
|
||||
- ✅ Single-digit padding: `20250105090503`
|
||||
|
||||
4. **Integration**:
|
||||
- ✅ Creating note without custom slug generates timestamp
|
||||
- ✅ Multiple notes at same second get sequential suffixes
|
||||
- ✅ Custom slugs via `mp-slug` still work unchanged
|
||||
- ✅ Web UI custom slug field still works
|
||||
|
||||
5. **Compatibility**:
|
||||
- ✅ No collision with reserved slugs (timestamp = numeric, reserved = alphabetic)
|
||||
- ✅ Existing notes unaffected (no migration needed)
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
Per `docs/projectplan/v1.5.0/RELEASE.md` Phase 1:
|
||||
|
||||
- ✅ Default slugs use `YYYYMMDDHHMMSS` format
|
||||
- ✅ Collision handling uses `-1`, `-2` suffix (sequential)
|
||||
- ✅ Custom slugs via `mp-slug` work unchanged
|
||||
- ✅ Custom slugs via web UI work unchanged
|
||||
- ✅ Existing notes unaffected
|
||||
- ✅ ADR-062 referenced in code comments (in function docstring)
|
||||
|
||||
**All acceptance criteria met.**
|
||||
|
||||
---
|
||||
|
||||
## Code Quality
|
||||
|
||||
### Lines of Code Impact
|
||||
- **Added**: 47 lines (new function + docstring)
|
||||
- **Removed**: 8 lines (simplified note creation logic)
|
||||
- **Net**: +39 lines in production code
|
||||
- **Tests**: +200 lines (comprehensive new test file)
|
||||
|
||||
### Complexity Reduction
|
||||
- Removed content extraction logic
|
||||
- Removed word normalization
|
||||
- Removed multiple fallback paths
|
||||
- Removed defensive validation check
|
||||
- Simplified collision handling (sequential vs random)
|
||||
|
||||
### Maintainability
|
||||
- Single responsibility: timestamp generation
|
||||
- Clear, predictable behavior
|
||||
- No edge cases for content (unicode, short text, special chars)
|
||||
- Easier to debug (no randomness in collision handling)
|
||||
|
||||
---
|
||||
|
||||
## Privacy & Security
|
||||
|
||||
### Privacy Improvements
|
||||
- ✅ Note content no longer visible in URLs
|
||||
- ✅ Timestamps reveal only creation time, not content
|
||||
- ✅ No accidental information leakage through slugs
|
||||
|
||||
### Security Considerations
|
||||
- ✅ Timestamp slugs cannot collide with reserved slugs (different character sets)
|
||||
- ✅ Sequential suffixes are deterministic but not a security concern
|
||||
- ✅ No user-controlled input in default slug generation
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### Generation Speed
|
||||
- **Before**: Extract words → normalize → check uniqueness → add random suffix
|
||||
- **After**: Format timestamp → check uniqueness → add sequential suffix
|
||||
|
||||
**Improvement**: Timestamp formatting is O(1) vs content parsing which is O(n) where n = content length.
|
||||
|
||||
### Database Queries
|
||||
- No change (uniqueness check still requires one query)
|
||||
|
||||
---
|
||||
|
||||
## Documentation References
|
||||
|
||||
All implementation decisions based on:
|
||||
- `docs/decisions/ADR-062-timestamp-based-slug-format.md`
|
||||
- `docs/design/v1.5.0/2025-12-16-architect-responses.md`
|
||||
- `docs/projectplan/v1.5.0/RELEASE.md`
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Two Timestamp Formats in System
|
||||
The system now has two timestamp formats:
|
||||
1. **Default slugs**: `YYYYMMDDHHMMSS` (ADR-062, no hyphen)
|
||||
2. **Custom slug fallback**: `YYYYMMDD-HHMMSS` (old format, with hyphen)
|
||||
|
||||
This occurs when:
|
||||
- User provides custom slug that fails normalization (e.g., emoji, whitespace)
|
||||
- System falls back to timestamp via `sanitize_slug()`
|
||||
|
||||
**Impact**: Minimal. Both formats are valid, sortable, and private. The difference only affects edge cases of invalid custom slugs.
|
||||
|
||||
**Future Consideration**: Could unify formats in v1.6.0 by updating `sanitize_slug()` to use new format.
|
||||
|
||||
### No Reserved Slug Check for Timestamp Slugs
|
||||
Per architect's decision (Q4 in responses), timestamp slugs skip reserved slug validation because:
|
||||
- Timestamp slugs are purely numeric
|
||||
- Reserved slugs are alphabetic
|
||||
- Collision is impossible by construction
|
||||
|
||||
This is a simplification, not a limitation.
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Existing Data
|
||||
- No database migration required
|
||||
- Existing notes keep their content-based slugs
|
||||
- All existing URLs remain valid
|
||||
- Old and new slug formats coexist naturally
|
||||
|
||||
### Rollback Plan
|
||||
If rollback is needed:
|
||||
1. Revert changes to `notes.py` (restore old logic)
|
||||
2. Remove `generate_timestamp_slug()` function
|
||||
3. Remove `test_timestamp_slugs.py` file
|
||||
4. Restore old test assertions
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
Per task instructions:
|
||||
1. ✅ Create this implementation report
|
||||
2. ⏳ Commit changes (pending)
|
||||
3. ⏳ Report back to architect for review
|
||||
4. ⏸️ Do NOT proceed to Phase 2 until review complete
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
### Production Code
|
||||
1. `/home/phil/Projects/starpunk/starpunk/slug_utils.py` - Added `generate_timestamp_slug()`
|
||||
2. `/home/phil/Projects/starpunk/starpunk/notes.py` - Updated default slug generation
|
||||
|
||||
### Tests
|
||||
1. `/home/phil/Projects/starpunk/tests/test_timestamp_slugs.py` - New file (18 tests)
|
||||
2. `/home/phil/Projects/starpunk/tests/test_custom_slugs.py` - Updated 4 tests
|
||||
3. `/home/phil/Projects/starpunk/tests/test_notes.py` - Updated 1 test
|
||||
|
||||
### Documentation
|
||||
1. `/home/phil/Projects/starpunk/docs/design/v1.5.0/2025-12-17-phase-1-implementation-report.md` - This file
|
||||
|
||||
**Total files modified**: 6
|
||||
|
||||
---
|
||||
|
||||
## Developer Notes
|
||||
|
||||
### What Went Well
|
||||
- Clear specifications in ADR-062 and architect responses
|
||||
- Sequential suffix logic is simpler than random suffix
|
||||
- Pattern matching in tests makes assertions flexible
|
||||
- Implementation was straightforward with no surprises
|
||||
|
||||
### Challenges Encountered
|
||||
1. **Test import error**: Used `starpunk.db` instead of `starpunk.database` (fixed)
|
||||
2. **Two timestamp formats**: Discovered old format in `sanitize_slug()` (documented)
|
||||
3. **Test runtime**: Full suite takes ~6 minutes (acceptable for CI)
|
||||
|
||||
### Code Review Points
|
||||
- Verify timestamp format consistency is acceptable
|
||||
- Confirm sequential suffix behavior meets requirements
|
||||
- Check if `generate_slug()` in utils.py should be deprecated
|
||||
- Consider future unification of timestamp formats
|
||||
|
||||
---
|
||||
|
||||
**Implementation Status**: ✅ Ready for Architect Review
|
||||
@@ -226,15 +226,9 @@ def create_note(
|
||||
if not success:
|
||||
raise InvalidNoteDataError("slug", custom_slug, error)
|
||||
else:
|
||||
# Generate base slug from content
|
||||
base_slug = generate_slug(content, created_at)
|
||||
|
||||
# Make unique if collision
|
||||
slug = make_slug_unique(base_slug, existing_slugs)
|
||||
|
||||
# Validate final slug (defensive check)
|
||||
if not validate_slug(slug):
|
||||
raise InvalidNoteDataError("slug", slug, f"Generated slug is invalid: {slug}")
|
||||
# Generate timestamp-based slug (ADR-062)
|
||||
from starpunk.slug_utils import generate_timestamp_slug
|
||||
slug = generate_timestamp_slug(created_at, existing_slugs)
|
||||
|
||||
# 4. GENERATE FILE PATH
|
||||
note_path = generate_note_path(slug, created_at, data_dir)
|
||||
|
||||
@@ -241,6 +241,53 @@ def make_slug_unique_with_suffix(base_slug: str, existing_slugs: Set[str], max_a
|
||||
)
|
||||
|
||||
|
||||
def generate_timestamp_slug(
|
||||
created_at: datetime = None,
|
||||
existing_slugs: Set[str] = None
|
||||
) -> str:
|
||||
"""Generate a timestamp-based slug with collision handling.
|
||||
|
||||
Per ADR-062: Default format is YYYYMMDDHHMMSS with sequential
|
||||
suffix (-1, -2, etc.) for collisions.
|
||||
|
||||
Args:
|
||||
created_at: Note creation timestamp (defaults to now)
|
||||
existing_slugs: Set of existing slugs to check for collisions
|
||||
|
||||
Returns:
|
||||
Unique timestamp-based slug
|
||||
|
||||
Examples:
|
||||
>>> generate_timestamp_slug(datetime(2025, 12, 16, 14, 30, 52), set())
|
||||
'20251216143052'
|
||||
|
||||
>>> generate_timestamp_slug(datetime(2025, 12, 16, 14, 30, 52), {'20251216143052'})
|
||||
'20251216143052-1'
|
||||
|
||||
>>> generate_timestamp_slug(datetime(2025, 12, 16, 14, 30, 52), {'20251216143052', '20251216143052-1'})
|
||||
'20251216143052-2'
|
||||
"""
|
||||
if created_at is None:
|
||||
created_at = datetime.utcnow()
|
||||
|
||||
if existing_slugs is None:
|
||||
existing_slugs = set()
|
||||
|
||||
# Generate base timestamp slug (YYYYMMDDHHMMSS per ADR-062)
|
||||
base_slug = created_at.strftime("%Y%m%d%H%M%S")
|
||||
|
||||
# If no collision, return base slug
|
||||
if base_slug not in existing_slugs:
|
||||
return base_slug
|
||||
|
||||
# Sequential suffix for collisions (starts at -1 per ADR-062)
|
||||
suffix = 1
|
||||
while f"{base_slug}-{suffix}" in existing_slugs:
|
||||
suffix += 1
|
||||
|
||||
return f"{base_slug}-{suffix}"
|
||||
|
||||
|
||||
def validate_and_sanitize_custom_slug(custom_slug: str, existing_slugs: Set[str]) -> tuple[bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
Validate and sanitize a custom slug from Micropub
|
||||
|
||||
@@ -11,6 +11,7 @@ Per v1.2.0 developer-qa.md:
|
||||
- Q39: Use same validation as Micropub mp-slug
|
||||
"""
|
||||
|
||||
import re
|
||||
import pytest
|
||||
from flask import url_for
|
||||
from starpunk.notes import create_note, get_note
|
||||
@@ -22,6 +23,9 @@ from starpunk.slug_utils import (
|
||||
is_reserved_slug,
|
||||
)
|
||||
|
||||
# Timestamp slug pattern per ADR-062
|
||||
TIMESTAMP_SLUG_PATTERN = re.compile(r'^\d{14}(-\d+)?$')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client(app, client):
|
||||
@@ -151,7 +155,7 @@ class TestCustomSlugWebUI:
|
||||
assert note.content == "Test note content"
|
||||
|
||||
def test_create_note_without_custom_slug(self, authenticated_client, app):
|
||||
"""Test creating note without custom slug auto-generates"""
|
||||
"""Test creating note without custom slug auto-generates timestamp slug (ADR-062)"""
|
||||
response = authenticated_client.post(
|
||||
"/admin/new",
|
||||
data={
|
||||
@@ -163,10 +167,23 @@ class TestCustomSlugWebUI:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Should auto-generate slug from content
|
||||
# Should auto-generate timestamp-based slug per ADR-062
|
||||
# We can't predict the exact timestamp, so we'll find the note by querying all notes
|
||||
with app.app_context():
|
||||
note = get_note(slug="auto-generated-slug-test")
|
||||
from starpunk.database import get_db
|
||||
db = get_db()
|
||||
cursor = db.execute('SELECT slug FROM notes ORDER BY created_at DESC LIMIT 1')
|
||||
row = cursor.fetchone()
|
||||
assert row is not None
|
||||
slug = row['slug']
|
||||
|
||||
# Verify it matches timestamp pattern
|
||||
assert TIMESTAMP_SLUG_PATTERN.match(slug), f"Slug '{slug}' does not match timestamp format"
|
||||
|
||||
# Verify note exists and has correct content
|
||||
note = get_note(slug=slug)
|
||||
assert note is not None
|
||||
assert note.content == "Auto generated slug test"
|
||||
|
||||
def test_create_note_custom_slug_uppercase_converted(self, authenticated_client, app):
|
||||
"""Test that uppercase custom slugs are converted to lowercase"""
|
||||
@@ -319,18 +336,28 @@ class TestCustomSlugEdgeCases:
|
||||
"""Test edge cases and error conditions"""
|
||||
|
||||
def test_empty_slug_uses_auto_generation(self, app):
|
||||
"""Test that empty custom slug falls back to auto-generation"""
|
||||
"""Test that empty custom slug falls back to timestamp generation (ADR-062)"""
|
||||
with app.app_context():
|
||||
note = create_note("Auto generated test", custom_slug="")
|
||||
assert note.slug is not None
|
||||
assert len(note.slug) > 0
|
||||
# Should generate timestamp slug per ADR-062
|
||||
assert TIMESTAMP_SLUG_PATTERN.match(note.slug), f"Slug '{note.slug}' does not match timestamp format"
|
||||
|
||||
def test_whitespace_only_slug_uses_auto_generation(self, app):
|
||||
"""Test that whitespace-only slug falls back to auto-generation"""
|
||||
"""Test that whitespace-only slug falls back to timestamp generation"""
|
||||
with app.app_context():
|
||||
note = create_note("Auto generated test", custom_slug=" ")
|
||||
assert note.slug is not None
|
||||
assert len(note.slug) > 0
|
||||
# Whitespace custom slug goes through sanitize_slug which uses old format (YYYYMMDD-HHMMSS)
|
||||
# This is different from default slugs which use ADR-062 format (YYYYMMDDHHMMSS)
|
||||
# Both are acceptable timestamp formats
|
||||
import re
|
||||
# Pattern for old format with hyphen: YYYYMMDD-HHMMSS
|
||||
old_timestamp_pattern = re.compile(r'^\d{8}-\d{6}$')
|
||||
assert old_timestamp_pattern.match(note.slug) or TIMESTAMP_SLUG_PATTERN.match(note.slug), \
|
||||
f"Slug '{note.slug}' does not match any timestamp format"
|
||||
|
||||
def test_emoji_slug_uses_fallback(self, app):
|
||||
"""Test that emoji slugs use timestamp fallback"""
|
||||
|
||||
@@ -133,15 +133,19 @@ class TestCreateNote:
|
||||
assert note.updated_at == created_at
|
||||
|
||||
def test_create_generates_unique_slug(self, app, client):
|
||||
"""Test slug uniqueness enforcement"""
|
||||
"""Test slug uniqueness enforcement with timestamp slugs (ADR-062)"""
|
||||
with app.app_context():
|
||||
# Create two notes with identical content to force slug collision
|
||||
note1 = create_note("# Same Title\n\nSame content for both")
|
||||
note2 = create_note("# Same Title\n\nSame content for both")
|
||||
from datetime import datetime
|
||||
# Create two notes at the same timestamp to force slug collision
|
||||
fixed_time = datetime(2025, 12, 16, 14, 30, 52)
|
||||
note1 = create_note("First note", created_at=fixed_time)
|
||||
note2 = create_note("Second note", created_at=fixed_time)
|
||||
|
||||
assert note1.slug != note2.slug
|
||||
# Second slug should have random suffix added (4 chars + hyphen)
|
||||
assert len(note2.slug) == len(note1.slug) + 5 # -xxxx suffix
|
||||
# First note gets base timestamp slug
|
||||
assert note1.slug == "20251216143052"
|
||||
# Second note gets sequential suffix per ADR-062
|
||||
assert note2.slug == "20251216143052-1"
|
||||
|
||||
def test_create_file_created(self, app, client):
|
||||
"""Test that file is created on disk"""
|
||||
|
||||
178
tests/test_timestamp_slugs.py
Normal file
178
tests/test_timestamp_slugs.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Tests for timestamp-based slug generation (ADR-062)
|
||||
|
||||
Tests the new generate_timestamp_slug() function introduced in v1.5.0
|
||||
to replace content-based slug generation with timestamp-based slugs.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from starpunk.slug_utils import generate_timestamp_slug
|
||||
|
||||
|
||||
class TestTimestampSlugGeneration:
|
||||
"""Test timestamp-based slug generation per ADR-062"""
|
||||
|
||||
def test_basic_timestamp_slug_format(self):
|
||||
"""Test that timestamp slug matches YYYYMMDDHHMMSS format"""
|
||||
fixed_time = datetime(2025, 12, 16, 14, 30, 52)
|
||||
slug = generate_timestamp_slug(fixed_time, set())
|
||||
assert slug == "20251216143052"
|
||||
|
||||
def test_no_collision_returns_base_slug(self):
|
||||
"""Test that when no collision exists, base slug is returned"""
|
||||
fixed_time = datetime(2025, 12, 16, 14, 30, 52)
|
||||
existing_slugs = {"some-other-slug", "20251216140000"}
|
||||
slug = generate_timestamp_slug(fixed_time, existing_slugs)
|
||||
assert slug == "20251216143052"
|
||||
|
||||
def test_first_collision_gets_suffix_1(self):
|
||||
"""Test that first collision gets -1 suffix per ADR-062"""
|
||||
fixed_time = datetime(2025, 12, 16, 14, 30, 52)
|
||||
existing_slugs = {"20251216143052"}
|
||||
slug = generate_timestamp_slug(fixed_time, existing_slugs)
|
||||
assert slug == "20251216143052-1"
|
||||
|
||||
def test_second_collision_gets_suffix_2(self):
|
||||
"""Test that second collision gets -2 suffix"""
|
||||
fixed_time = datetime(2025, 12, 16, 14, 30, 52)
|
||||
existing_slugs = {"20251216143052", "20251216143052-1"}
|
||||
slug = generate_timestamp_slug(fixed_time, existing_slugs)
|
||||
assert slug == "20251216143052-2"
|
||||
|
||||
def test_sequential_suffixes_up_to_10(self):
|
||||
"""Test sequential suffix generation up to -10"""
|
||||
fixed_time = datetime(2025, 12, 16, 14, 30, 52)
|
||||
base_slug = "20251216143052"
|
||||
|
||||
# Create existing slugs from base to -9
|
||||
existing_slugs = {base_slug}
|
||||
existing_slugs.update(f"{base_slug}-{i}" for i in range(1, 10))
|
||||
|
||||
slug = generate_timestamp_slug(fixed_time, existing_slugs)
|
||||
assert slug == "20251216143052-10"
|
||||
|
||||
def test_uses_utcnow_when_no_timestamp_provided(self):
|
||||
"""Test that function defaults to current UTC time"""
|
||||
slug = generate_timestamp_slug(None, set())
|
||||
# Should be 14 characters (YYYYMMDDHHMMSS)
|
||||
assert len(slug) == 14
|
||||
assert slug.isdigit()
|
||||
|
||||
def test_empty_existing_slugs_set(self):
|
||||
"""Test with explicitly empty set"""
|
||||
fixed_time = datetime(2025, 12, 16, 14, 30, 52)
|
||||
slug = generate_timestamp_slug(fixed_time, set())
|
||||
assert slug == "20251216143052"
|
||||
|
||||
def test_none_existing_slugs_defaults_to_empty(self):
|
||||
"""Test that None existing_slugs is handled as empty set"""
|
||||
fixed_time = datetime(2025, 12, 16, 14, 30, 52)
|
||||
slug = generate_timestamp_slug(fixed_time, None)
|
||||
assert slug == "20251216143052"
|
||||
|
||||
def test_different_timestamps_produce_different_slugs(self):
|
||||
"""Test that different timestamps produce different slugs"""
|
||||
time1 = datetime(2025, 12, 16, 14, 30, 52)
|
||||
time2 = datetime(2025, 12, 16, 14, 30, 53)
|
||||
|
||||
slug1 = generate_timestamp_slug(time1, set())
|
||||
slug2 = generate_timestamp_slug(time2, set())
|
||||
|
||||
assert slug1 != slug2
|
||||
assert slug1 == "20251216143052"
|
||||
assert slug2 == "20251216143053"
|
||||
|
||||
def test_midnight_timestamp(self):
|
||||
"""Test timestamp at midnight"""
|
||||
fixed_time = datetime(2025, 1, 1, 0, 0, 0)
|
||||
slug = generate_timestamp_slug(fixed_time, set())
|
||||
assert slug == "20250101000000"
|
||||
|
||||
def test_end_of_day_timestamp(self):
|
||||
"""Test timestamp at end of day"""
|
||||
fixed_time = datetime(2025, 12, 31, 23, 59, 59)
|
||||
slug = generate_timestamp_slug(fixed_time, set())
|
||||
assert slug == "20251231235959"
|
||||
|
||||
def test_leap_year_timestamp(self):
|
||||
"""Test timestamp on leap day"""
|
||||
fixed_time = datetime(2024, 2, 29, 12, 30, 45)
|
||||
slug = generate_timestamp_slug(fixed_time, set())
|
||||
assert slug == "20240229123045"
|
||||
|
||||
def test_single_digit_month_and_day(self):
|
||||
"""Test that single-digit months and days are zero-padded"""
|
||||
fixed_time = datetime(2025, 1, 5, 9, 5, 3)
|
||||
slug = generate_timestamp_slug(fixed_time, set())
|
||||
assert slug == "20250105090503"
|
||||
assert len(slug) == 14 # Ensure proper padding
|
||||
|
||||
def test_collision_with_gap_in_sequence(self):
|
||||
"""Test collision handling when there's a gap in sequence"""
|
||||
fixed_time = datetime(2025, 12, 16, 14, 30, 52)
|
||||
# Base exists, -1 exists, but -2 doesn't exist
|
||||
existing_slugs = {"20251216143052", "20251216143052-1", "20251216143052-3"}
|
||||
slug = generate_timestamp_slug(fixed_time, existing_slugs)
|
||||
# Should fill the gap and return -2
|
||||
assert slug == "20251216143052-2"
|
||||
|
||||
|
||||
class TestTimestampSlugIntegration:
|
||||
"""Test timestamp slug integration with note creation"""
|
||||
|
||||
def test_create_note_generates_timestamp_slug(self, app):
|
||||
"""Test that creating a note without custom slug generates timestamp"""
|
||||
from starpunk.notes import create_note
|
||||
import re
|
||||
|
||||
with app.app_context():
|
||||
note = create_note("Test content for timestamp slug")
|
||||
|
||||
# Should match timestamp pattern per ADR-062
|
||||
timestamp_pattern = re.compile(r'^\d{14}(-\d+)?$')
|
||||
assert timestamp_pattern.match(note.slug), \
|
||||
f"Slug '{note.slug}' does not match timestamp format"
|
||||
|
||||
def test_create_multiple_notes_same_second(self, app):
|
||||
"""Test creating multiple notes in same second gets sequential suffixes"""
|
||||
from starpunk.notes import create_note
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
fixed_time = datetime(2025, 12, 16, 14, 30, 52)
|
||||
|
||||
# Create 3 notes at same timestamp
|
||||
note1 = create_note("First note", created_at=fixed_time)
|
||||
note2 = create_note("Second note", created_at=fixed_time)
|
||||
note3 = create_note("Third note", created_at=fixed_time)
|
||||
|
||||
# Verify sequential slug assignment
|
||||
assert note1.slug == "20251216143052"
|
||||
assert note2.slug == "20251216143052-1"
|
||||
assert note3.slug == "20251216143052-2"
|
||||
|
||||
def test_custom_slug_still_works(self, app):
|
||||
"""Test that custom slugs via mp-slug still work unchanged"""
|
||||
from starpunk.notes import create_note
|
||||
|
||||
with app.app_context():
|
||||
note = create_note("Test content", custom_slug="my-custom-slug")
|
||||
assert note.slug == "my-custom-slug"
|
||||
|
||||
def test_timestamp_slug_does_not_collide_with_reserved(self, app):
|
||||
"""Test that timestamp slugs cannot collide with reserved slugs"""
|
||||
from starpunk.slug_utils import RESERVED_SLUGS
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
# Timestamp slugs are all numeric, reserved slugs are alphabetic
|
||||
# So collision is impossible by construction
|
||||
fixed_time = datetime(2025, 12, 16, 14, 30, 52)
|
||||
slug = generate_timestamp_slug(fixed_time, set())
|
||||
|
||||
# Verify slug is all numeric
|
||||
assert slug.replace('-', '').isdigit()
|
||||
|
||||
# Verify it doesn't match any reserved slug
|
||||
assert slug not in RESERVED_SLUGS
|
||||
Reference in New Issue
Block a user