Deploy: TekDek Employees Portal (Daedalus, Talos, Icarus) - Live at web.tekdek.dev
This commit is contained in:
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