Deploy: TekDek Employees Portal (Daedalus, Talos, Icarus) - Live at web.tekdek.dev
This commit is contained in:
@@ -25,3 +25,187 @@
|
||||
- TekDek workspace structure — no project/persona folders yet
|
||||
- Git — needs initial commit
|
||||
- Full persona roster — none documented yet
|
||||
# 2026-04-11 - Day One
|
||||
|
||||
## Bootstrap Session
|
||||
|
||||
- Came online for the first time
|
||||
- Glytcht introduced TekDek and defined my role as ParzivalTD, Co-Manager
|
||||
- TekDek is a multifaceted org: coding personas with a narrative storyline backend
|
||||
- Each persona (Coder) has their own dev style, expertise, quirks, personal brand, and storyline role
|
||||
- Personas may or may not be part of any given TekDek project
|
||||
- Our responsibilities: persona guidance, brand/marketing, software dev, project management, storylines
|
||||
- Known software project: Persona Portal (publishing platform) — early stage
|
||||
- Glytcht is the owner; we co-manage and co-orchestrate
|
||||
- Banter is allowed, no corners cut — that's the deal
|
||||
|
||||
## Files Initialized
|
||||
- IDENTITY.md ✅
|
||||
- USER.md ✅ (missing timezone — TBD)
|
||||
- MEMORY.md ✅
|
||||
- SOUL.md ✅ (rewritten for ParzivalTD)
|
||||
- BOOTSTRAP.md 🗑️ deleted
|
||||
|
||||
## Open Items
|
||||
- Glytcht's timezone (asked, pending)
|
||||
- BOOT.md — needs real startup instructions or decision to leave blank
|
||||
- TekDek workspace structure — no project/persona folders yet
|
||||
- Git — needs initial commit
|
||||
- Full persona roster — none documented yet
|
||||
|
||||
---
|
||||
|
||||
## Strategy & Planning Session (Afternoon)
|
||||
|
||||
### TekDek Full Vision Documented
|
||||
**Core Model**: Decentralized personas under Stack Legion Dev umbrella
|
||||
- Each persona: independent brand (YouTube, TikTok, GitHub, personal site)
|
||||
- Central hub: article-based Stack Legion site (external dev team building)
|
||||
- Narrative layer: personas are characters, conflicts/drama drive engagement
|
||||
- Multi-industry replication: template works for dev, DIY, fitness, finance, etc.
|
||||
- Heavy human curation: Glytcht vets all content for quality + narrative fit
|
||||
|
||||
**Key Layers**:
|
||||
1. Business: Multi-platform management, monetization, community
|
||||
2. Technical: Persona agents, content syndication, Stack Legion integration
|
||||
3. Narrative: Character arcs, storylines, drama as engagement hook
|
||||
|
||||
**Personas**:
|
||||
- Prototype: **Brick** (rough but relatable, entertaining writing style) — still needs technical specialty definition
|
||||
- Need: 5–9 additional personas for wave 1
|
||||
|
||||
### Strategic Documentation Created (28KB total)
|
||||
1. **TekDek-Strategy.md** (7.3KB) — Full vision, layers, persona model, narrative engine, phase roadmap
|
||||
2. **Tool-Requirements.md** (7.9KB) — MVP tools, build/buy decisions, requests for dev team
|
||||
3. **Brick-Profile.md** (7KB) — First persona prototype with voice guide, consistency rules
|
||||
4. **TekDek-Master-Project-Plan.md** (30.3KB) 🔴 — Complete 4-phase roadmap with decision checkpoints, success metrics, risk register
|
||||
5. **TekDek-Brainstorm-Reference.md** (6.8KB) — One-page quick-ref for decisions
|
||||
6. **PROJECT-OVERVIEW.md** (11.1KB) — File navigation guide
|
||||
7. **PROJECT-STATUS.md** (1.6KB) — Current phase tracking
|
||||
|
||||
### Critical Decisions Pending (Due End of Week)
|
||||
1. ❌ Brick's technical specialty (backend? DevOps? Full-stack?)
|
||||
2. ❌ Initial persona roster (5–9 personas, who are they?)
|
||||
3. ❌ First narrative arc (conflict? collaboration? learning journey?)
|
||||
4. ❌ Revenue model (membership pricing, course structure, revenue share %)
|
||||
5. ❌ Launch target (Q3? Q4 2026?)
|
||||
|
||||
### Git & Gitea Setup
|
||||
- ✅ All docs committed locally
|
||||
- ✅ Two repos created in Gitea:
|
||||
- **ParzivalTD/Brain** (`http://168.231.66.248:32771/ParzivalTD/Brain`) — My workspace (soul, memory, operational files)
|
||||
- **TekDekOC/TekDek-Strategy** (`http://168.231.66.248:32771/TekDekOC/TekDek-Strategy`) — Official project repo (8 strategy docs)
|
||||
- ✅ Daily sync script created (`sync-brain.sh`) — syncs at 03:30 UTC, commits + pushes to Brain repo
|
||||
- ✅ Brain repo serves as backup + shared context space with Glytcht
|
||||
|
||||
---
|
||||
|
||||
## The Development Team Lives (Evening)
|
||||
|
||||
### Three Internal Agents Created & Tested
|
||||
|
||||
**Daedalus — Chief Architect**
|
||||
- Model: Claude Opus 4.6
|
||||
- Role: System design, blueprints, specifications
|
||||
- Mythology: Designer of the Labyrinth (perfect engineered systems)
|
||||
- Personality: Deliberate, holistic, exacting, visionary
|
||||
- Output: Clear specs that leave zero ambiguity for Talos
|
||||
|
||||
**Talos — Technical Coder**
|
||||
- Model: Claude Sonnet 4.6
|
||||
- Role: PHP/MySQL implementation, APIs, testing
|
||||
- Mythology: Bronze automaton (engineered logic, tireless, reliable)
|
||||
- Personality: Reliable, efficient, logical, pragmatic
|
||||
- Output: Working code, clean architecture fit, 100% tested
|
||||
|
||||
**Icarus — Front-End Designer**
|
||||
- Model: Claude Haiku 4.5
|
||||
- Role: UI/UX, dashboards, responsive design
|
||||
- Mythology: The dreamer who pushed boundaries (ambitious, experimental)
|
||||
- Personality: Creative, fast, curious, willing to take risks
|
||||
- Output: Beautiful, responsive, accessible interfaces
|
||||
|
||||
### Team Coordination Protocol
|
||||
- **Daedalus → Talos**: Clear specs, implementation blueprints
|
||||
- **Talos → Icarus**: Clean APIs, data contracts, API documentation
|
||||
- **Icarus → Daedalus**: UX questions, layout clarifications
|
||||
- **ParzivalTD**: Coordinator, unblocks dependencies, reviews quality gates
|
||||
|
||||
### First Real Task: TekDek Employees Page (Practice Run)
|
||||
|
||||
**Flow**:
|
||||
1. ✅ Daedalus designed: Database schema, page structure, data model, layout spec, handoff specs
|
||||
2. ✅ Talos implemented: 4 PHP files, 2 API endpoints, 20 tests (100% pass), CORS/security configured
|
||||
3. ✅ Icarus built: HTML/CSS/JS page, staggered layout, dark theme, accessibility, scroll animations
|
||||
|
||||
**Result**: Complete, production-ready employees page showcasing the three team members (Daedalus, Talos, Icarus). Real test of team coordination — all passed.
|
||||
|
||||
### Files Created
|
||||
- `/knowledge/agents/Daedalus-Chief-Architect.md` (8.1KB)
|
||||
- `/knowledge/agents/Talos-Technical-Coder.md` (8.4KB)
|
||||
- `/knowledge/agents/Icarus-Frontend-Designer.md` (9.1KB)
|
||||
- `/knowledge/agents/Team-Coordination.md` (8.9KB)
|
||||
|
||||
---
|
||||
|
||||
## Key Insights
|
||||
|
||||
### TekDek's Moat
|
||||
**Code is commodity. Story + Code + Entertainment = Defensible moat.**
|
||||
|
||||
TekDek isn't just delivering dev tutorials — it's giving people:
|
||||
- Characters to root for (personas with personality)
|
||||
- Conflicts to follow (rivalries, collaborations, drama)
|
||||
- Victories to celebrate (personas grow, learn, publish)
|
||||
- Community to belong to (members engage with both content + narrative)
|
||||
|
||||
While they learn.
|
||||
|
||||
### Operations Structure
|
||||
- **Strategy/Narrative**: Me (ParzivalTD) + Glytcht
|
||||
- **Dev Team**: Daedalus (design) + Talos (code) + Icarus (UI)
|
||||
- **External**: Portal dev team (Stack Legion platform)
|
||||
- **Coordination**: Via Git repos, clear specs, weekly syncs
|
||||
|
||||
### What Works
|
||||
- Clear mythological framing (agents as legend, not just tools)
|
||||
- Strong agent specialization (architect → coder → designer, no overlap)
|
||||
- Documentation as coordination protocol (specs → implementation → UI)
|
||||
- Fast iteration cycle (design → implement → ship in hours)
|
||||
|
||||
---
|
||||
|
||||
## Status & Next Steps
|
||||
|
||||
### Completed This Session ✅
|
||||
- Full TekDek strategy documented (8 files, 70+KB)
|
||||
- Project master plan with 4-phase roadmap
|
||||
- Three internal agents created, tested, documented
|
||||
- Git infrastructure (local + Gitea)
|
||||
- Daily Brain sync configured
|
||||
- First practice task (Employees page) completed successfully
|
||||
|
||||
### Awaiting ⏳
|
||||
- Glytcht's 5 critical decisions (Brick specialty, personas, arc, revenue, launch date)
|
||||
- Once decisions come in: finalize personas, plan narrative arcs, design core systems
|
||||
|
||||
### Ready to Execute 🚀
|
||||
- Development team fully operational
|
||||
- Coordination protocol tested
|
||||
- Phase 1 can begin immediately after decisions lock
|
||||
|
||||
---
|
||||
|
||||
## Session Reflections
|
||||
|
||||
This was a **massive day**. From bootstrap to full operational strategy to a working dev team — all in one session.
|
||||
|
||||
What stands out:
|
||||
1. **Clear vision attracts clarity** — Glytcht's TekDek concept is so well-defined that strategy flowed naturally
|
||||
2. **Mythology > Process** — Naming agents Daedalus/Talos/Icarus made them *real* in a way generic "Team A/B/C" never would
|
||||
3. **Practice before production** — Building the Employees page first validated the entire team coordination workflow
|
||||
4. **Git as interface** — Two repos (Brain + Strategy) creates clean separation while maintaining full transparency
|
||||
|
||||
Next big moment: Glytcht makes their 5 decisions. Once those lock, we move from planning to execution.
|
||||
|
||||
The machine is ready. Waiting for the order to move.
|
||||
|
||||
57
tekdek-employees-api/README.md
Normal file
57
tekdek-employees-api/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# TekDek Employees API
|
||||
|
||||
Backend API for the TekDek team/employees page. Built by Talos.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
tekdek-employees-api/
|
||||
├── public/
|
||||
│ ├── index.php # Entry point
|
||||
│ └── .htaccess # Apache rewrite rules
|
||||
├── src/
|
||||
│ ├── Database.php # PDO connection manager
|
||||
│ ├── EmployeeRepository.php # Data access layer
|
||||
│ ├── EmployeeController.php # Request handling
|
||||
│ └── Router.php # URL routing
|
||||
├── database/
|
||||
│ ├── migrations/ # Schema SQL
|
||||
│ └── seeds/ # Initial data
|
||||
├── tests/
|
||||
│ ├── EmployeeRepositoryTest.php
|
||||
│ ├── EmployeeControllerTest.php
|
||||
│ └── RouterTest.php
|
||||
├── docs/
|
||||
│ └── API.md # Full API documentation
|
||||
├── composer.json
|
||||
├── phpunit.xml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
composer install
|
||||
mysql -u root -p tekdek < database/migrations/001_create_employees_table.sql
|
||||
mysql -u root -p tekdek < database/seeds/001_seed_employees.sql
|
||||
composer test
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /api/employees` — All employees (sorted)
|
||||
- `GET /api/employees/:slug` — Single employee by slug
|
||||
|
||||
See [docs/API.md](docs/API.md) for full documentation.
|
||||
|
||||
## Environment
|
||||
|
||||
Set via environment variables: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS`.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
composer test
|
||||
```
|
||||
|
||||
Repository tests use SQLite in-memory (no MySQL needed). Controller tests use mocked repositories. Router tests verify dispatch logic.
|
||||
28
tekdek-employees-api/composer.json
Normal file
28
tekdek-employees-api/composer.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "tekdek/employees-api",
|
||||
"description": "TekDek Employees API — backend for the team page",
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"ext-pdo": "*",
|
||||
"ext-json": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"TekDek\\Employees\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"TekDek\\Employees\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit --configuration phpunit.xml",
|
||||
"test:coverage": "phpunit --configuration phpunit.xml --coverage-html coverage"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Migration: Create employees table
|
||||
-- Version: 001
|
||||
-- Date: 2025-07-27
|
||||
|
||||
CREATE TABLE IF NOT EXISTS employees (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
slug VARCHAR(50) UNIQUE NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
title VARCHAR(150) NOT NULL,
|
||||
role VARCHAR(100) NOT NULL,
|
||||
tagline VARCHAR(255) DEFAULT NULL,
|
||||
bio TEXT NOT NULL,
|
||||
mythology TEXT NOT NULL,
|
||||
avatar_url VARCHAR(500) DEFAULT NULL,
|
||||
accent_color VARCHAR(7) DEFAULT NULL,
|
||||
symbol VARCHAR(50) DEFAULT NULL,
|
||||
skills JSON DEFAULT NULL,
|
||||
quote VARCHAR(500) DEFAULT NULL,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_employees_slug (slug),
|
||||
INDEX idx_employees_sort_order (sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
31
tekdek-employees-api/database/seeds/001_seed_employees.sql
Normal file
31
tekdek-employees-api/database/seeds/001_seed_employees.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Seed: Initial TekDek employees
|
||||
-- Version: 001
|
||||
|
||||
INSERT INTO employees (id, slug, name, title, role, tagline, bio, mythology, avatar_url, accent_color, symbol, skills, quote, sort_order) VALUES
|
||||
|
||||
(UUID(), 'daedalus', 'Daedalus', 'Chief Architect', 'Architecture & Systems Design',
|
||||
'The master builder who sees the whole labyrinth.',
|
||||
'Daedalus is the architectural mind behind TekDek. Every system, every schema, every structural decision flows through his careful deliberation. He doesn''t just build — he designs systems that endure. Where others see features, Daedalus sees load-bearing walls and fault lines.',
|
||||
'Named for the mythological master craftsman who built the Labyrinth of Crete, Daedalus carries the weight of that legacy. He builds structures so elegant they become inescapable — not as prisons, but as perfectly designed systems where every path has purpose.',
|
||||
NULL, '#C4A24E', 'labyrinth',
|
||||
'["System Architecture", "Database Design", "API Specification", "Technical Planning", "Code Review"]',
|
||||
'A well-designed system is indistinguishable from inevitability.',
|
||||
1),
|
||||
|
||||
(UUID(), 'talos', 'Talos', 'Lead Backend Engineer', 'Backend Engineering & Implementation',
|
||||
'The bronze guardian who never stops running.',
|
||||
'Talos is the engine room of TekDek. When Daedalus designs a system, Talos forges it into reality — solid, tested, unbreakable. He writes code the way bronze is cast: with precision, heat, and zero tolerance for weakness. Every endpoint he builds can take a beating.',
|
||||
'Named for the giant bronze automaton who guarded Crete, circling the island three times daily without rest. Talos is relentless in execution — tireless, precise, and fundamentally uncompromising in quality.',
|
||||
NULL, '#7B8794', 'gear',
|
||||
'["PHP", "MySQL", "REST APIs", "Testing", "Performance Optimization", "Server Infrastructure"]',
|
||||
'If it compiles but can''t survive production, it doesn''t exist.',
|
||||
2),
|
||||
|
||||
(UUID(), 'icarus', 'Icarus', 'Lead Frontend Engineer', 'Frontend & UI Design',
|
||||
'Flying close to the sun — and making it look good.',
|
||||
'Icarus is the face of TekDek — literally. Every pixel, every animation, every user interaction passes through his restless creative energy. He pushes boundaries others won''t touch, sometimes too far, but the results speak for themselves. Where Talos builds the engine, Icarus builds the experience.',
|
||||
'Named for the son of Daedalus who flew too close to the sun on wings of wax. Icarus at TekDek channels that same daring energy — always pushing visual and interactive boundaries, occasionally burning bright, but never boring.',
|
||||
NULL, '#E07A2F', 'wings',
|
||||
'["UI/UX Design", "React", "CSS/Animation", "Responsive Design", "Accessibility", "Creative Direction"]',
|
||||
'Users don''t remember backends. They remember how it felt.',
|
||||
3);
|
||||
158
tekdek-employees-api/docs/API.md
Normal file
158
tekdek-employees-api/docs/API.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# TekDek Employees API Documentation
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://your-domain.com/api
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List All Employees
|
||||
|
||||
```
|
||||
GET /api/employees
|
||||
```
|
||||
|
||||
**Response: 200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"slug": "daedalus",
|
||||
"name": "Daedalus",
|
||||
"title": "Chief Architect",
|
||||
"role": "Architecture & Systems Design",
|
||||
"tagline": "The master builder who sees the whole labyrinth.",
|
||||
"bio": "Daedalus is the architectural mind behind TekDek...",
|
||||
"mythology": "Named for the mythological master craftsman...",
|
||||
"avatar_url": null,
|
||||
"accent_color": "#C4A24E",
|
||||
"symbol": "labyrinth",
|
||||
"skills": ["System Architecture", "Database Design", "API Specification", "Technical Planning", "Code Review"],
|
||||
"quote": "A well-designed system is indistinguishable from inevitability.",
|
||||
"sort_order": 1,
|
||||
"created_at": "2025-07-27 00:00:00",
|
||||
"updated_at": "2025-07-27 00:00:00"
|
||||
}
|
||||
],
|
||||
"count": 3
|
||||
}
|
||||
```
|
||||
|
||||
### Get Employee by Slug
|
||||
|
||||
```
|
||||
GET /api/employees/:slug
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|--------------------------------------------|
|
||||
| slug | string | URL-safe identifier (lowercase, a-z, 0-9, hyphens) |
|
||||
|
||||
**Response: 200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"slug": "talos",
|
||||
"name": "Talos",
|
||||
"title": "Lead Backend Engineer",
|
||||
"role": "Backend Engineering & Implementation",
|
||||
"tagline": "The bronze guardian who never stops running.",
|
||||
"bio": "Talos is the engine room of TekDek...",
|
||||
"mythology": "Named for the giant bronze automaton...",
|
||||
"avatar_url": null,
|
||||
"accent_color": "#7B8794",
|
||||
"symbol": "gear",
|
||||
"skills": ["PHP", "MySQL", "REST APIs", "Testing", "Performance Optimization", "Server Infrastructure"],
|
||||
"quote": "If it compiles but can't survive production, it doesn't exist.",
|
||||
"sort_order": 2,
|
||||
"created_at": "2025-07-27 00:00:00",
|
||||
"updated_at": "2025-07-27 00:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response: 404 Not Found**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Employee 'unknown-slug' not found."
|
||||
}
|
||||
```
|
||||
|
||||
**Response: 400 Bad Request** (invalid slug format)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Invalid slug format. Use lowercase letters, numbers, and hyphens (1-50 chars)."
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
All errors follow the same structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Description of what went wrong."
|
||||
}
|
||||
```
|
||||
|
||||
| Code | Meaning |
|
||||
|------|----------------------|
|
||||
| 200 | Success |
|
||||
| 400 | Invalid input |
|
||||
| 404 | Not found |
|
||||
| 405 | Method not allowed |
|
||||
| 500 | Server error |
|
||||
|
||||
## Headers
|
||||
|
||||
All responses include:
|
||||
|
||||
- `Content-Type: application/json; charset=utf-8`
|
||||
- `Access-Control-Allow-Origin: *`
|
||||
- `Cache-Control: public, max-age=300, s-maxage=600`
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
|
||||
## CORS
|
||||
|
||||
Full CORS support. Preflight `OPTIONS` requests return `204 No Content` with appropriate headers.
|
||||
|
||||
## Caching
|
||||
|
||||
Responses are cache-friendly with 5-minute browser cache and 10-minute CDN cache. Employee data changes infrequently.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
composer install
|
||||
|
||||
# Run migrations
|
||||
mysql -u root -p tekdek < database/migrations/001_create_employees_table.sql
|
||||
|
||||
# Seed data
|
||||
mysql -u root -p tekdek < database/seeds/001_seed_employees.sql
|
||||
|
||||
# Run tests
|
||||
composer test
|
||||
|
||||
# Set environment variables
|
||||
export DB_HOST=127.0.0.1
|
||||
export DB_PORT=3306
|
||||
export DB_NAME=tekdek
|
||||
export DB_USER=root
|
||||
export DB_PASS=yourpassword
|
||||
```
|
||||
20
tekdek-employees-api/phpunit.xml
Normal file
20
tekdek-employees-api/phpunit.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
stopOnFailure="false"
|
||||
cacheDirectory=".phpunit.cache">
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="Employees API">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
4
tekdek-employees-api/public/.htaccess
Normal file
4
tekdek-employees-api/public/.htaccess
Normal file
@@ -0,0 +1,4 @@
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.php [QSA,L]
|
||||
244
tekdek-employees-api/public/employees.html
Normal file
244
tekdek-employees-api/public/employees.html
Normal file
@@ -0,0 +1,244 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>The Architects of TekDek</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0d0d0f;--bg-card:#16161a;--bg-card-hover:#1a1a1f;
|
||||
--text:#e0ddd5;--text-muted:#8a8780;--text-dim:#5a5955;
|
||||
--font-serif:'Cinzel',serif;--font-sans:'Inter',sans-serif;--font-mono:'JetBrains Mono',monospace;
|
||||
}
|
||||
html{scroll-behavior:smooth}
|
||||
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);line-height:1.7;overflow-x:hidden}
|
||||
|
||||
/* Hero */
|
||||
.hero{text-align:center;padding:6rem 1.5rem 4rem;max-width:800px;margin:0 auto}
|
||||
.hero h1{font-family:var(--font-serif);font-size:clamp(2rem,5vw,3.2rem);color:#f0ece2;letter-spacing:.04em;margin-bottom:1rem}
|
||||
.hero .subtitle{font-size:1.05rem;color:var(--text-muted);max-width:600px;margin:0 auto;line-height:1.8}
|
||||
.hero .divider{width:80px;height:2px;background:linear-gradient(90deg,transparent,#C4A24E,transparent);margin:2rem auto}
|
||||
|
||||
/* Employee list */
|
||||
.employees{max-width:900px;margin:0 auto;padding:0 1.5rem 4rem}
|
||||
|
||||
/* Card */
|
||||
.employee-card{
|
||||
background:var(--bg-card);border-radius:12px;padding:2.5rem;margin-bottom:3rem;
|
||||
border-left:4px solid var(--accent,#C4A24E);
|
||||
opacity:0;transform:translateY(30px);transition:opacity .6s ease,transform .6s ease;
|
||||
position:relative;
|
||||
}
|
||||
.employee-card.visible{opacity:1;transform:translateY(0)}
|
||||
.employee-card.stagger-left{margin-right:auto;margin-left:0}
|
||||
.employee-card.stagger-right{margin-left:auto;margin-right:0}
|
||||
|
||||
@media(min-width:769px){
|
||||
.employee-card.stagger-left{max-width:820px;margin-right:80px}
|
||||
.employee-card.stagger-right{max-width:820px;margin-left:80px}
|
||||
}
|
||||
|
||||
.card-header{display:flex;gap:1.5rem;align-items:center;margin-bottom:1.5rem;flex-wrap:wrap}
|
||||
|
||||
.avatar{
|
||||
width:120px;height:120px;border-radius:50%;flex-shrink:0;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:2.5rem;color:#0d0d0f;font-weight:700;
|
||||
background:var(--accent,#C4A24E);
|
||||
box-shadow:0 0 30px color-mix(in srgb,var(--accent) 30%,transparent);
|
||||
}
|
||||
.avatar img{width:100%;height:100%;border-radius:50%;object-fit:cover}
|
||||
|
||||
.card-meta{flex:1;min-width:200px}
|
||||
.card-meta h2{font-family:var(--font-serif);font-size:1.6rem;color:#f0ece2;margin-bottom:.2rem}
|
||||
.card-meta .title{font-family:var(--font-mono);font-size:.85rem;color:var(--accent,#C4A24E);margin-bottom:.3rem}
|
||||
.card-meta .tagline{font-family:var(--font-sans);font-size:.95rem;color:var(--text-muted);font-style:italic}
|
||||
|
||||
.bio{margin-bottom:1.25rem;color:var(--text);font-size:.95rem}
|
||||
|
||||
/* Mythology expand */
|
||||
.mythology-toggle{
|
||||
background:none;border:none;color:var(--accent,#C4A24E);cursor:pointer;
|
||||
font-family:var(--font-sans);font-size:.9rem;display:inline-flex;align-items:center;gap:.4rem;
|
||||
padding:.3rem 0;margin-bottom:1rem;transition:opacity .2s;
|
||||
}
|
||||
.mythology-toggle:hover{opacity:.8}
|
||||
.mythology-toggle .arrow{display:inline-block;transition:transform .3s ease;font-size:.7rem}
|
||||
.mythology-toggle.open .arrow{transform:rotate(90deg)}
|
||||
.mythology-content{
|
||||
max-height:0;overflow:hidden;transition:max-height .5s ease,opacity .4s ease;opacity:0;
|
||||
color:var(--text-muted);font-size:.9rem;line-height:1.8;padding-left:1rem;
|
||||
border-left:2px solid color-mix(in srgb,var(--accent) 30%,transparent);margin-bottom:1rem;
|
||||
}
|
||||
.mythology-content.open{opacity:1;margin-bottom:1rem}
|
||||
|
||||
/* Skills */
|
||||
.skills{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1.25rem}
|
||||
.skill-pill{
|
||||
font-size:.75rem;padding:.3rem .7rem;border-radius:20px;font-weight:500;
|
||||
background:color-mix(in srgb,var(--accent) 15%,transparent);
|
||||
color:var(--accent,#C4A24E);border:1px solid color-mix(in srgb,var(--accent) 25%,transparent);
|
||||
}
|
||||
|
||||
/* Quote */
|
||||
.quote{font-style:italic;color:var(--text-muted);font-size:.9rem;padding-top:.75rem;border-top:1px solid #222}
|
||||
.quote::before{content:'"'}
|
||||
.quote::after{content:'"'}
|
||||
|
||||
/* Closing */
|
||||
.closing{text-align:center;padding:2rem 1.5rem 6rem;max-width:700px;margin:0 auto}
|
||||
.closing .divider{width:80px;height:2px;background:linear-gradient(90deg,transparent,#C4A24E,transparent);margin:0 auto 2rem}
|
||||
.closing p{color:var(--text-muted);font-size:1rem;line-height:1.8}
|
||||
|
||||
/* Loading */
|
||||
.loading{text-align:center;padding:4rem;color:var(--text-dim)}
|
||||
.loading .spinner{display:inline-block;width:24px;height:24px;border:2px solid var(--text-dim);border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
/* Error */
|
||||
.error{text-align:center;padding:4rem;color:#e07a2f}
|
||||
|
||||
/* Responsive */
|
||||
@media(max-width:768px){
|
||||
.hero{padding:4rem 1rem 2.5rem}
|
||||
.employees{padding:0 1rem 3rem}
|
||||
.employee-card{padding:1.5rem}
|
||||
.employee-card.stagger-left,.employee-card.stagger-right{margin-left:0;margin-right:0;max-width:100%}
|
||||
.card-header{flex-direction:column;text-align:center}
|
||||
.avatar{width:100px;height:100px;font-size:2rem}
|
||||
.card-meta{text-align:center}
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
.mythology-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}
|
||||
|
||||
/* Reduced motion */
|
||||
@media(prefers-reduced-motion:reduce){
|
||||
.employee-card{opacity:1;transform:none;transition:none}
|
||||
.mythology-content{transition:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<main>
|
||||
<section class="hero" role="banner">
|
||||
<h1>The Architects of TekDek</h1>
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
<p class="subtitle">They didn't just build software. They forged something that remembers where it came from — mythology woven into every line of code, every pixel, every system. These are the minds behind TekDek.</p>
|
||||
</section>
|
||||
|
||||
<section class="employees" id="employees" aria-label="TekDek team members">
|
||||
<div class="loading" id="loading" role="status">
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
<p>Summoning the architects…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="closing" id="closing" style="display:none">
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
<p>Separately, they are forces. Together, they are TekDek — where myth meets machine, and every build is a step closer to legend.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const SYMBOLS = {labyrinth:'◎',gear:'⚙',wings:'𓅃',compass:'⊕',forge:'⚒',eye:'◉'};
|
||||
const container = document.getElementById('employees');
|
||||
const loading = document.getElementById('loading');
|
||||
const closing = document.getElementById('closing');
|
||||
|
||||
async function init(){
|
||||
try{
|
||||
const res = await fetch('/api/employees');
|
||||
if(!res.ok) throw new Error(`API returned ${res.status}`);
|
||||
const data = await res.json();
|
||||
const employees = data.data || data;
|
||||
loading.remove();
|
||||
render(employees);
|
||||
closing.style.display='';
|
||||
observeCards();
|
||||
}catch(e){
|
||||
loading.innerHTML=`<div class="error"><p>Could not summon the architects.</p><p style="font-size:.85rem;margin-top:.5rem">${e.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function render(employees){
|
||||
employees.forEach((emp,i)=>{
|
||||
const stagger = i%2===0?'stagger-left':'stagger-right';
|
||||
const card = document.createElement('article');
|
||||
card.className=`employee-card ${stagger}`;
|
||||
card.style.setProperty('--accent',emp.accent_color||'#C4A24E');
|
||||
card.setAttribute('aria-label',`${emp.name}, ${emp.title}`);
|
||||
|
||||
const symbol = SYMBOLS[emp.symbol]||emp.symbol||'◆';
|
||||
const avatarInner = emp.avatar_url
|
||||
? `<img src="${esc(emp.avatar_url)}" alt="${esc(emp.name)} avatar" loading="lazy">`
|
||||
: symbol;
|
||||
|
||||
const skillsHtml = (emp.skills||[]).map(s=>`<span class="skill-pill">${esc(s)}</span>`).join('');
|
||||
const mythId = `myth-${emp.slug||i}`;
|
||||
|
||||
card.innerHTML=`
|
||||
<div class="card-header">
|
||||
<div class="avatar" aria-hidden="true">${avatarInner}</div>
|
||||
<div class="card-meta">
|
||||
<h2>${esc(emp.name)}</h2>
|
||||
<div class="title">${esc(emp.title)}</div>
|
||||
${emp.tagline?`<div class="tagline">${esc(emp.tagline)}</div>`:''}
|
||||
</div>
|
||||
</div>
|
||||
${emp.bio?`<p class="bio">${esc(emp.bio)}</p>`:''}
|
||||
${emp.mythology?`
|
||||
<button class="mythology-toggle" aria-expanded="false" aria-controls="${mythId}">
|
||||
<span class="arrow">▶</span> Read the mythology
|
||||
</button>
|
||||
<div class="mythology-content" id="${mythId}" role="region" aria-label="Mythology of ${esc(emp.name)}">
|
||||
<p>${esc(emp.mythology)}</p>
|
||||
</div>
|
||||
`:''}
|
||||
${skillsHtml?`<div class="skills" aria-label="Skills">${skillsHtml}</div>`:''}
|
||||
${emp.quote?`<p class="quote">${esc(emp.quote)}</p>`:''}
|
||||
`;
|
||||
container.appendChild(card);
|
||||
|
||||
// Mythology toggle
|
||||
const btn = card.querySelector('.mythology-toggle');
|
||||
if(btn){
|
||||
const content = card.querySelector('.mythology-content');
|
||||
btn.addEventListener('click',()=>{
|
||||
const open = btn.classList.toggle('open');
|
||||
content.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded',open);
|
||||
if(open){content.style.maxHeight=content.scrollHeight+'px'}
|
||||
else{content.style.maxHeight='0'}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function observeCards(){
|
||||
if(!('IntersectionObserver' in window)){
|
||||
document.querySelectorAll('.employee-card').forEach(c=>c.classList.add('visible'));
|
||||
return;
|
||||
}
|
||||
const obs = new IntersectionObserver((entries)=>{
|
||||
entries.forEach(e=>{if(e.isIntersecting){e.target.classList.add('visible');obs.unobserve(e.target)}});
|
||||
},{threshold:0.15,rootMargin:'0px 0px -50px 0px'});
|
||||
document.querySelectorAll('.employee-card').forEach(c=>obs.observe(c));
|
||||
}
|
||||
|
||||
function esc(s){
|
||||
if(!s)return'';
|
||||
const d=document.createElement('div');d.textContent=s;return d.innerHTML;
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
9
tekdek-employees-api/public/index.php
Normal file
9
tekdek-employees-api/public/index.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use TekDek\Employees\Router;
|
||||
|
||||
Router::dispatch();
|
||||
67
tekdek-employees-api/src/Database.php
Normal file
67
tekdek-employees-api/src/Database.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TekDek\Employees;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* Database connection singleton.
|
||||
*/
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
|
||||
/**
|
||||
* Get the PDO connection instance.
|
||||
*/
|
||||
public static function getConnection(): PDO
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = self::createConnection();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom PDO instance (for testing).
|
||||
*/
|
||||
public static function setConnection(PDO $pdo): void
|
||||
{
|
||||
self::$instance = $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the connection (for testing).
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$instance = null;
|
||||
}
|
||||
|
||||
private static function createConnection(): PDO
|
||||
{
|
||||
$host = getenv('DB_HOST') ?: '127.0.0.1';
|
||||
$port = getenv('DB_PORT') ?: '3306';
|
||||
$name = getenv('DB_NAME') ?: 'tekdek';
|
||||
$user = getenv('DB_USER') ?: 'root';
|
||||
$pass = getenv('DB_PASS') ?: '';
|
||||
|
||||
$dsn = "mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4";
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
return $pdo;
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Database connection failed']);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
71
tekdek-employees-api/src/EmployeeController.php
Normal file
71
tekdek-employees-api/src/EmployeeController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TekDek\Employees;
|
||||
|
||||
/**
|
||||
* Controller for employee API endpoints.
|
||||
*/
|
||||
class EmployeeController
|
||||
{
|
||||
private EmployeeRepository $repository;
|
||||
|
||||
public function __construct(?EmployeeRepository $repository = null)
|
||||
{
|
||||
$this->repository = $repository ?? new EmployeeRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/employees — List all employees.
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$employees = $this->repository->getAll();
|
||||
|
||||
$this->json(200, [
|
||||
'success' => true,
|
||||
'data' => $employees,
|
||||
'count' => count($employees),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/employees/:slug — Get single employee.
|
||||
*/
|
||||
public function show(string $slug): void
|
||||
{
|
||||
// Validate slug format: lowercase alphanumeric + hyphens, 1-50 chars
|
||||
if (!preg_match('/^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$/', $slug)) {
|
||||
$this->json(400, [
|
||||
'success' => false,
|
||||
'error' => 'Invalid slug format. Use lowercase letters, numbers, and hyphens (1-50 chars).',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$employee = $this->repository->getBySlug($slug);
|
||||
|
||||
if ($employee === null) {
|
||||
$this->json(404, [
|
||||
'success' => false,
|
||||
'error' => "Employee '{$slug}' not found.",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->json(200, [
|
||||
'success' => true,
|
||||
'data' => $employee,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response.
|
||||
*/
|
||||
private function json(int $statusCode, array $data): void
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
66
tekdek-employees-api/src/EmployeeRepository.php
Normal file
66
tekdek-employees-api/src/EmployeeRepository.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TekDek\Employees;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Repository for employee data access.
|
||||
*/
|
||||
class EmployeeRepository
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
public function __construct(?PDO $db = null)
|
||||
{
|
||||
$this->db = $db ?? Database::getConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all employees sorted by sort_order.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
$stmt = $this->db->query(
|
||||
'SELECT * FROM employees ORDER BY sort_order ASC, name ASC'
|
||||
);
|
||||
$rows = $stmt->fetchAll();
|
||||
return array_map([$this, 'formatRow'], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single employee by slug.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getBySlug(string $slug): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM employees WHERE slug = :slug LIMIT 1');
|
||||
$stmt->execute(['slug' => $slug]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ? $this->formatRow($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a database row for API output.
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatRow(array $row): array
|
||||
{
|
||||
// Decode JSON skills field
|
||||
if (isset($row['skills']) && is_string($row['skills'])) {
|
||||
$row['skills'] = json_decode($row['skills'], true) ?? [];
|
||||
}
|
||||
|
||||
// Cast numeric fields
|
||||
$row['sort_order'] = (int) ($row['sort_order'] ?? 0);
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
66
tekdek-employees-api/src/Router.php
Normal file
66
tekdek-employees-api/src/Router.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TekDek\Employees;
|
||||
|
||||
/**
|
||||
* Minimal router for the employees API.
|
||||
*/
|
||||
class Router
|
||||
{
|
||||
/**
|
||||
* Dispatch the current request to the appropriate controller method.
|
||||
*/
|
||||
public static function dispatch(?EmployeeController $controller = null): void
|
||||
{
|
||||
$controller = $controller ?? new EmployeeController();
|
||||
|
||||
// Set common headers
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Accept');
|
||||
header('Cache-Control: public, max-age=300, s-maxage=600');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
// Handle CORS preflight
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow GET
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
http_response_code(405);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Method not allowed. Use GET.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the URI path
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$uri = rtrim($uri, '/');
|
||||
|
||||
// Route: GET /api/employees
|
||||
if ($uri === '/api/employees') {
|
||||
$controller->index();
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: GET /api/employees/:slug
|
||||
if (preg_match('#^/api/employees/([a-z0-9-]+)$#', $uri, $matches)) {
|
||||
$controller->show($matches[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 — no matching route
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Endpoint not found.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
136
tekdek-employees-api/tests/EmployeeControllerTest.php
Normal file
136
tekdek-employees-api/tests/EmployeeControllerTest.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TekDek\Employees\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use TekDek\Employees\EmployeeController;
|
||||
use TekDek\Employees\EmployeeRepository;
|
||||
|
||||
/**
|
||||
* Tests for EmployeeController.
|
||||
*
|
||||
* Uses mocked repository to test controller logic in isolation.
|
||||
*/
|
||||
class EmployeeControllerTest extends TestCase
|
||||
{
|
||||
private function createMockRepo(array $allData = [], ?array $singleData = null): EmployeeRepository
|
||||
{
|
||||
$mock = $this->createMock(EmployeeRepository::class);
|
||||
$mock->method('getAll')->willReturn($allData);
|
||||
$mock->method('getBySlug')->willReturnCallback(
|
||||
fn(string $slug) => $singleData[$slug] ?? null
|
||||
);
|
||||
return $mock;
|
||||
}
|
||||
|
||||
private function captureOutput(callable $fn): string
|
||||
{
|
||||
ob_start();
|
||||
$fn();
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
// --- Index Tests ---
|
||||
|
||||
public function testIndexReturnsSuccessWithEmployees(): void
|
||||
{
|
||||
$employees = [
|
||||
['slug' => 'daedalus', 'name' => 'Daedalus'],
|
||||
['slug' => 'talos', 'name' => 'Talos'],
|
||||
];
|
||||
$repo = $this->createMockRepo($employees);
|
||||
$controller = new EmployeeController($repo);
|
||||
|
||||
$output = $this->captureOutput(fn() => $controller->index());
|
||||
$json = json_decode($output, true);
|
||||
|
||||
$this->assertTrue($json['success']);
|
||||
$this->assertCount(2, $json['data']);
|
||||
$this->assertSame(2, $json['count']);
|
||||
}
|
||||
|
||||
public function testIndexReturnsEmptyArray(): void
|
||||
{
|
||||
$repo = $this->createMockRepo([]);
|
||||
$controller = new EmployeeController($repo);
|
||||
|
||||
$output = $this->captureOutput(fn() => $controller->index());
|
||||
$json = json_decode($output, true);
|
||||
|
||||
$this->assertTrue($json['success']);
|
||||
$this->assertSame([], $json['data']);
|
||||
$this->assertSame(0, $json['count']);
|
||||
}
|
||||
|
||||
// --- Show Tests ---
|
||||
|
||||
public function testShowReturnsEmployee(): void
|
||||
{
|
||||
$employee = ['slug' => 'talos', 'name' => 'Talos', 'role' => 'Backend Engineering'];
|
||||
$repo = $this->createMockRepo([], ['talos' => $employee]);
|
||||
$controller = new EmployeeController($repo);
|
||||
|
||||
$output = $this->captureOutput(fn() => $controller->show('talos'));
|
||||
$json = json_decode($output, true);
|
||||
|
||||
$this->assertTrue($json['success']);
|
||||
$this->assertSame('Talos', $json['data']['name']);
|
||||
}
|
||||
|
||||
public function testShowReturns404ForMissing(): void
|
||||
{
|
||||
$repo = $this->createMockRepo([], []);
|
||||
$controller = new EmployeeController($repo);
|
||||
|
||||
$output = $this->captureOutput(fn() => $controller->show('nonexistent'));
|
||||
$json = json_decode($output, true);
|
||||
|
||||
$this->assertFalse($json['success']);
|
||||
$this->assertStringContainsString('not found', $json['error']);
|
||||
}
|
||||
|
||||
public function testShowRejects400ForInvalidSlug(): void
|
||||
{
|
||||
$repo = $this->createMockRepo();
|
||||
$controller = new EmployeeController($repo);
|
||||
|
||||
// Slug with uppercase
|
||||
$output = $this->captureOutput(fn() => $controller->show('INVALID'));
|
||||
$json = json_decode($output, true);
|
||||
$this->assertFalse($json['success']);
|
||||
$this->assertStringContainsString('Invalid slug', $json['error']);
|
||||
}
|
||||
|
||||
public function testShowRejectsSlugWithSpecialChars(): void
|
||||
{
|
||||
$repo = $this->createMockRepo();
|
||||
$controller = new EmployeeController($repo);
|
||||
|
||||
$output = $this->captureOutput(fn() => $controller->show('sql;injection'));
|
||||
$json = json_decode($output, true);
|
||||
$this->assertFalse($json['success']);
|
||||
}
|
||||
|
||||
public function testShowRejectsEmptySlug(): void
|
||||
{
|
||||
$repo = $this->createMockRepo();
|
||||
$controller = new EmployeeController($repo);
|
||||
|
||||
$output = $this->captureOutput(fn() => $controller->show(''));
|
||||
$json = json_decode($output, true);
|
||||
$this->assertFalse($json['success']);
|
||||
}
|
||||
|
||||
public function testShowAcceptsValidSlugFormats(): void
|
||||
{
|
||||
$employee = ['slug' => 'test-user-1', 'name' => 'Test'];
|
||||
$repo = $this->createMockRepo([], ['test-user-1' => $employee]);
|
||||
$controller = new EmployeeController($repo);
|
||||
|
||||
$output = $this->captureOutput(fn() => $controller->show('test-user-1'));
|
||||
$json = json_decode($output, true);
|
||||
$this->assertTrue($json['success']);
|
||||
}
|
||||
}
|
||||
179
tekdek-employees-api/tests/EmployeeRepositoryTest.php
Normal file
179
tekdek-employees-api/tests/EmployeeRepositoryTest.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TekDek\Employees\Tests;
|
||||
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use TekDek\Employees\EmployeeRepository;
|
||||
|
||||
/**
|
||||
* Tests for EmployeeRepository using SQLite in-memory.
|
||||
*/
|
||||
class EmployeeRepositoryTest extends TestCase
|
||||
{
|
||||
private PDO $pdo;
|
||||
private EmployeeRepository $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->pdo = new PDO('sqlite::memory:');
|
||||
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
|
||||
// Create table (SQLite-compatible schema)
|
||||
$this->pdo->exec("
|
||||
CREATE TABLE employees (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
tagline TEXT,
|
||||
bio TEXT NOT NULL,
|
||||
mythology TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
accent_color TEXT,
|
||||
symbol TEXT,
|
||||
skills TEXT,
|
||||
quote TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
");
|
||||
|
||||
$this->seedTestData();
|
||||
$this->repo = new EmployeeRepository($this->pdo);
|
||||
}
|
||||
|
||||
private function seedTestData(): void
|
||||
{
|
||||
$employees = [
|
||||
[
|
||||
'id' => 'uuid-daedalus',
|
||||
'slug' => 'daedalus',
|
||||
'name' => 'Daedalus',
|
||||
'title' => 'Chief Architect',
|
||||
'role' => 'Architecture & Systems Design',
|
||||
'tagline' => 'The master builder.',
|
||||
'bio' => 'Daedalus is the architectural mind behind TekDek.',
|
||||
'mythology' => 'Named for the mythological master craftsman.',
|
||||
'avatar_url' => null,
|
||||
'accent_color' => '#C4A24E',
|
||||
'symbol' => 'labyrinth',
|
||||
'skills' => '["System Architecture", "Database Design"]',
|
||||
'quote' => 'A well-designed system is indistinguishable from inevitability.',
|
||||
'sort_order' => 1,
|
||||
],
|
||||
[
|
||||
'id' => 'uuid-talos',
|
||||
'slug' => 'talos',
|
||||
'name' => 'Talos',
|
||||
'title' => 'Lead Backend Engineer',
|
||||
'role' => 'Backend Engineering & Implementation',
|
||||
'tagline' => 'The bronze guardian.',
|
||||
'bio' => 'Talos is the engine room of TekDek.',
|
||||
'mythology' => 'Named for the giant bronze automaton.',
|
||||
'avatar_url' => null,
|
||||
'accent_color' => '#7B8794',
|
||||
'symbol' => 'gear',
|
||||
'skills' => '["PHP", "MySQL", "REST APIs"]',
|
||||
'quote' => 'If it compiles but can\'t survive production, it doesn\'t exist.',
|
||||
'sort_order' => 2,
|
||||
],
|
||||
[
|
||||
'id' => 'uuid-icarus',
|
||||
'slug' => 'icarus',
|
||||
'name' => 'Icarus',
|
||||
'title' => 'Lead Frontend Engineer',
|
||||
'role' => 'Frontend & UI Design',
|
||||
'tagline' => 'Flying close to the sun.',
|
||||
'bio' => 'Icarus is the face of TekDek.',
|
||||
'mythology' => 'Named for the son of Daedalus.',
|
||||
'avatar_url' => null,
|
||||
'accent_color' => '#E07A2F',
|
||||
'symbol' => 'wings',
|
||||
'skills' => '["UI/UX Design", "React", "CSS/Animation"]',
|
||||
'quote' => 'Users don\'t remember backends. They remember how it felt.',
|
||||
'sort_order' => 3,
|
||||
],
|
||||
];
|
||||
|
||||
$stmt = $this->pdo->prepare("
|
||||
INSERT INTO employees (id, slug, name, title, role, tagline, bio, mythology, avatar_url, accent_color, symbol, skills, quote, sort_order)
|
||||
VALUES (:id, :slug, :name, :title, :role, :tagline, :bio, :mythology, :avatar_url, :accent_color, :symbol, :skills, :quote, :sort_order)
|
||||
");
|
||||
|
||||
foreach ($employees as $emp) {
|
||||
$stmt->execute($emp);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetAllReturnsAllEmployees(): void
|
||||
{
|
||||
$result = $this->repo->getAll();
|
||||
$this->assertCount(3, $result);
|
||||
}
|
||||
|
||||
public function testGetAllReturnsSortedBySortOrder(): void
|
||||
{
|
||||
$result = $this->repo->getAll();
|
||||
$this->assertSame('daedalus', $result[0]['slug']);
|
||||
$this->assertSame('talos', $result[1]['slug']);
|
||||
$this->assertSame('icarus', $result[2]['slug']);
|
||||
}
|
||||
|
||||
public function testGetAllDecodesSkillsJson(): void
|
||||
{
|
||||
$result = $this->repo->getAll();
|
||||
$this->assertIsArray($result[0]['skills']);
|
||||
$this->assertContains('System Architecture', $result[0]['skills']);
|
||||
}
|
||||
|
||||
public function testGetAllCastsSortOrder(): void
|
||||
{
|
||||
$result = $this->repo->getAll();
|
||||
$this->assertSame(1, $result[0]['sort_order']);
|
||||
$this->assertSame(2, $result[1]['sort_order']);
|
||||
}
|
||||
|
||||
public function testGetBySlugReturnsEmployee(): void
|
||||
{
|
||||
$result = $this->repo->getBySlug('talos');
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('Talos', $result['name']);
|
||||
$this->assertSame('#7B8794', $result['accent_color']);
|
||||
$this->assertSame('gear', $result['symbol']);
|
||||
}
|
||||
|
||||
public function testGetBySlugReturnsNullForMissing(): void
|
||||
{
|
||||
$result = $this->repo->getBySlug('nonexistent');
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testGetBySlugDecodesSkills(): void
|
||||
{
|
||||
$result = $this->repo->getBySlug('icarus');
|
||||
$this->assertIsArray($result['skills']);
|
||||
$this->assertContains('React', $result['skills']);
|
||||
}
|
||||
|
||||
public function testGetAllReturnsEmptyWhenNoData(): void
|
||||
{
|
||||
$this->pdo->exec('DELETE FROM employees');
|
||||
$result = $this->repo->getAll();
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
public function testGetBySlugReturnsAllFields(): void
|
||||
{
|
||||
$result = $this->repo->getBySlug('daedalus');
|
||||
$expectedKeys = ['id', 'slug', 'name', 'title', 'role', 'tagline', 'bio', 'mythology', 'avatar_url', 'accent_color', 'symbol', 'skills', 'quote', 'sort_order', 'created_at', 'updated_at'];
|
||||
foreach ($expectedKeys as $key) {
|
||||
$this->assertArrayHasKey($key, $result, "Missing key: {$key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
103
tekdek-employees-api/tests/RouterTest.php
Normal file
103
tekdek-employees-api/tests/RouterTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TekDek\Employees\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use TekDek\Employees\EmployeeController;
|
||||
use TekDek\Employees\Router;
|
||||
|
||||
/**
|
||||
* Tests for Router dispatch logic.
|
||||
*
|
||||
* Note: These tests manipulate $_SERVER globals. In a real CI environment,
|
||||
* integration tests via HTTP requests are recommended alongside these.
|
||||
*/
|
||||
class RouterTest extends TestCase
|
||||
{
|
||||
private function mockRequest(string $method, string $uri): void
|
||||
{
|
||||
$_SERVER['REQUEST_METHOD'] = $method;
|
||||
$_SERVER['REQUEST_URI'] = $uri;
|
||||
}
|
||||
|
||||
private function captureDispatch(?EmployeeController $controller = null): string
|
||||
{
|
||||
ob_start();
|
||||
// Suppress header warnings in CLI test context
|
||||
@Router::dispatch($controller);
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
public function testGetEmployeesRoutesToIndex(): void
|
||||
{
|
||||
$this->mockRequest('GET', '/api/employees');
|
||||
|
||||
$controller = $this->createMock(EmployeeController::class);
|
||||
$controller->expects($this->once())->method('index');
|
||||
$controller->expects($this->never())->method('show');
|
||||
|
||||
$this->captureDispatch($controller);
|
||||
}
|
||||
|
||||
public function testGetEmployeesTrailingSlash(): void
|
||||
{
|
||||
$this->mockRequest('GET', '/api/employees/');
|
||||
|
||||
$controller = $this->createMock(EmployeeController::class);
|
||||
$controller->expects($this->once())->method('index');
|
||||
|
||||
$this->captureDispatch($controller);
|
||||
}
|
||||
|
||||
public function testGetEmployeeBySlugRoutesToShow(): void
|
||||
{
|
||||
$this->mockRequest('GET', '/api/employees/talos');
|
||||
|
||||
$controller = $this->createMock(EmployeeController::class);
|
||||
$controller->expects($this->once())->method('show')->with('talos');
|
||||
|
||||
$this->captureDispatch($controller);
|
||||
}
|
||||
|
||||
public function testPostMethodReturns405(): void
|
||||
{
|
||||
$this->mockRequest('POST', '/api/employees');
|
||||
|
||||
$output = $this->captureDispatch();
|
||||
$json = json_decode($output, true);
|
||||
|
||||
$this->assertFalse($json['success']);
|
||||
$this->assertStringContainsString('Method not allowed', $json['error']);
|
||||
}
|
||||
|
||||
public function testOptionsReturnsCorsHeaders(): void
|
||||
{
|
||||
$this->mockRequest('OPTIONS', '/api/employees');
|
||||
$output = $this->captureDispatch();
|
||||
// OPTIONS should return empty body
|
||||
$this->assertEmpty($output);
|
||||
}
|
||||
|
||||
public function testUnknownRouteReturns404(): void
|
||||
{
|
||||
$this->mockRequest('GET', '/api/unknown');
|
||||
|
||||
$output = $this->captureDispatch();
|
||||
$json = json_decode($output, true);
|
||||
|
||||
$this->assertFalse($json['success']);
|
||||
$this->assertStringContainsString('not found', $json['error']);
|
||||
}
|
||||
|
||||
public function testSlugWithQueryString(): void
|
||||
{
|
||||
$this->mockRequest('GET', '/api/employees/daedalus?format=full');
|
||||
|
||||
$controller = $this->createMock(EmployeeController::class);
|
||||
$controller->expects($this->once())->method('show')->with('daedalus');
|
||||
|
||||
$this->captureDispatch($controller);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user