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