From 3f1f82a7498d0c734a777445e06e9974e2f60381 Mon Sep 17 00:00:00 2001 From: Phil Skentelbery Date: Wed, 17 Dec 2025 09:49:30 -0700 Subject: [PATCH] feat(slugs): Implement timestamp-based slugs per ADR-062 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...025-12-17-phase-1-implementation-report.md | 310 ++++++++++++++++++ starpunk/notes.py | 12 +- starpunk/slug_utils.py | 47 +++ tests/test_custom_slugs.py | 37 ++- tests/test_notes.py | 16 +- tests/test_timestamp_slugs.py | 178 ++++++++++ 6 files changed, 580 insertions(+), 20 deletions(-) create mode 100644 docs/design/v1.5.0/2025-12-17-phase-1-implementation-report.md create mode 100644 tests/test_timestamp_slugs.py diff --git a/docs/design/v1.5.0/2025-12-17-phase-1-implementation-report.md b/docs/design/v1.5.0/2025-12-17-phase-1-implementation-report.md new file mode 100644 index 0000000..70a445c --- /dev/null +++ b/docs/design/v1.5.0/2025-12-17-phase-1-implementation-report.md @@ -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 diff --git a/starpunk/notes.py b/starpunk/notes.py index 5440092..a72de69 100644 --- a/starpunk/notes.py +++ b/starpunk/notes.py @@ -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) diff --git a/starpunk/slug_utils.py b/starpunk/slug_utils.py index af598e0..5bbc95f 100644 --- a/starpunk/slug_utils.py +++ b/starpunk/slug_utils.py @@ -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 diff --git a/tests/test_custom_slugs.py b/tests/test_custom_slugs.py index 5dc11ce..da40052 100644 --- a/tests/test_custom_slugs.py +++ b/tests/test_custom_slugs.py @@ -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""" diff --git a/tests/test_notes.py b/tests/test_notes.py index a251501..64264af 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -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""" diff --git a/tests/test_timestamp_slugs.py b/tests/test_timestamp_slugs.py new file mode 100644 index 0000000..ee2f6d7 --- /dev/null +++ b/tests/test_timestamp_slugs.py @@ -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