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>
179 lines
7.2 KiB
Python
179 lines
7.2 KiB
Python
"""
|
|
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
|