diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 6c9be79..f260605 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,53 +1,88 @@
-# Use the official Dev Container base image for Debian Bookworm
+# Use the official Dev Container base image for Debian Trixie
# We use this instead of plain Debian to get a better development experience out of the box
# as it includes common tools and configurations for development.
-FROM mcr.microsoft.com/devcontainers/base:bookworm AS base
+FROM mcr.microsoft.com/devcontainers/base:trixie AS base
LABEL org.opencontainers.image.description="Development Container for Swindon Makerspace Access System"
# libparse-debianchangelog-perl \
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
- perl \
- cpanminus \
- build-essential \
- libalgorithm-diff-perl \
- libalgorithm-diff-xs-perl \
- libalgorithm-merge-perl \
- libcgi-fast-perl \
- libcgi-pm-perl \
- libclass-accessor-perl \
- libclass-isa-perl \
- libencode-locale-perl \
- libfcgi-perl \
- libfile-fcntllock-perl \
- libhtml-parser-perl \
- libhtml-tagset-perl \
- libhttp-date-perl \
- libhttp-message-perl \
- libio-html-perl \
- libio-string-perl \
- liblocale-gettext-perl \
- liblwp-mediatypes-perl \
- libsub-name-perl \
- libswitch-perl \
- libtext-charwidth-perl \
- libtext-iconv-perl \
- libtext-wrapi18n-perl \
- libtimedate-perl \
- liburi-perl \
- libscalar-list-utils-perl \
- && cpanm -S Carton \
-# for Perl::LanguageServer
+ perl \
+ cpanminus \
+ build-essential \
+ # Database drivers
+ libdbd-sqlite3-perl \
+ libdbd-pg-perl \
+ libpq-dev \
+ # Libraries for CPAN XS modules
+ libexpat1-dev \
+ libxml2-dev \
+ zlib1g-dev \
+ # Core Perl modules from apt
+ libalgorithm-diff-perl \
+ libalgorithm-diff-xs-perl \
+ libalgorithm-merge-perl \
+ libcgi-fast-perl \
+ libcgi-pm-perl \
+ libclass-accessor-perl \
+ libclass-isa-perl \
+ libencode-locale-perl \
+ libfcgi-perl \
+ libfile-fcntllock-perl \
+ libhtml-parser-perl \
+ libhtml-tagset-perl \
+ libhttp-date-perl \
+ libhttp-message-perl \
+ libio-html-perl \
+ libio-string-perl \
+ liblocale-gettext-perl \
+ liblwp-mediatypes-perl \
+ libsub-name-perl \
+ libswitch-perl \
+ libtext-charwidth-perl \
+ libtext-iconv-perl \
+ libtext-wrapi18n-perl \
+ libtimedate-perl \
+ liburi-perl \
+ libscalar-list-utils-perl \
+ libmoose-perl \
+ libjson-perl \
+ libdata-dump-perl \
+ libtry-tiny-perl \
+ libdatetime-perl \
+ libpath-class-perl \
+ libplack-perl \
+ libxml-parser-perl \
+ libcrypt-des-perl \
+ libssl-dev \
+ libio-socket-ssl-perl \
+ libnet-ssleay-perl \
+ ca-certificates \
+ && cpanm -n Carton \
+ # for Perl::LanguageServer
&& apt-get -y install --no-install-recommends \
- libanyevent-perl \
- libclass-refresh-perl \
- libdata-dump-perl \
- libio-aio-perl \
- libjson-perl \
- libmoose-perl \
- libpadwalker-perl \
- libscalar-list-utils-perl \
- libcoro-perl \
+ libanyevent-perl \
+ libclass-refresh-perl \
+ libio-aio-perl \
+ libpadwalker-perl \
+ libcoro-perl \
&& cpanm Perl::LanguageServer \
- && rm -rf /var/lib/apt/lists/*
+ && rm -rf /var/lib/apt/lists/* /root/.cpanm
+
+# =============================================================================
+# DEPS STAGE - Install CPAN dependencies via Carton
+# =============================================================================
+FROM base AS deps
+
+WORKDIR /workspace
+
+# Copy dependency files first (for better layer caching)
+COPY cpanfile cpanfile.snapshot ./
+
+# Create vendor directory structure (for cached install)
+COPY vendor/ vendor/
+
+# Install dependencies using cached mode
+RUN carton install --cached \
+ && rm -rf /root/.cpanm
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..0b35f24
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,54 @@
+# Git
+.git/
+.gitignore
+.github/
+
+# Development files
+.devcontainer/
+.vscode/
+.idea/
+*.code-workspace
+
+# Database files (should not be in image)
+db/
+*.db
+*.db.bak
+rapidapp_coreschema.db
+
+# Documentation
+docs/
+*.md
+NOTES.txt
+Changes
+
+# Config backups and local configs (mount at runtime)
+config.bkp/
+accesssystem_api.conf
+accesssystem_api_local.conf
+accesssystem_dev.conf
+accesssystem.conf
+keys.conf
+
+# Keep example configs
+!*.conf.example
+
+# Build artifacts
+Makefile
+META.yml
+MYMETA.*
+pm_to_blib
+blib/
+inc/
+
+# Editor/OS files
+*~
+*.bak
+.DS_Store
+*.swp
+
+# OFX bank files
+ofx/
+*.ofx
+
+# Test output
+test_db.db
diff --git a/.github/workflows/build-dev-image.yaml b/.github/workflows/build-dev-image.yaml
index 44b4cac..bf4526b 100644
--- a/.github/workflows/build-dev-image.yaml
+++ b/.github/workflows/build-dev-image.yaml
@@ -70,7 +70,7 @@ jobs:
with:
context: .
file: .devcontainer/Dockerfile
- target: base
+ target: deps
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..9f58a18
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,99 @@
+name: CI - Test and Build
+
+on:
+ push:
+ branches:
+ - master
+ tags:
+ - 'v*'
+ pull_request:
+ branches:
+ - master
+ workflow_dispatch:
+
+env:
+ CONTAINER_REGISTRY: ghcr.io
+ IMAGE_NAME: access-system
+
+jobs:
+ test:
+ name: ๐งช Run Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: ๐ฆ Checkout code
+ uses: actions/checkout@v6
+
+ - name: ๐ ๏ธ Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: ๐๏ธ Build test image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ./Dockerfile
+ target: test
+ load: true
+ tags: accesssystem-test:latest
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: ๐งช Run tests
+ run: |
+ docker run --rm accesssystem-test:latest
+
+ build-production:
+ name: ๐ Build Production Image
+ runs-on: ubuntu-latest
+ needs: test
+ if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/'))
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: ๐ฆ Checkout code
+ uses: actions/checkout@v6
+
+ - name: ๐ท๏ธ Generate Docker metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ ${{ env.CONTAINER_REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch,priority=610
+ type=semver,pattern={{raw}}
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=semver,pattern={{major}}
+ type=sha,format=long
+ type=raw,value=latest,enable={{is_default_branch}}
+ annotations: |
+ runnumber=${{ github.run_id }}
+ sha=${{ github.sha }}
+ ref=${{ github.ref }}
+ org.opencontainers.image.description="Production Container for Swindon Makerspace Access System"
+
+ - name: ๐ Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.CONTAINER_REGISTRY }}
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: ๐ ๏ธ Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: ๐ Build and push production image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ./Dockerfile
+ target: production
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ annotations: ${{ steps.meta.outputs.annotations }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..bc2c849
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,135 @@
+# Multi-stage Dockerfile for AccessSystem
+# Stages: base -> deps -> test -> production
+
+# =============================================================================
+# BASE STAGE - System dependencies and Perl packages from apt
+# =============================================================================
+FROM debian:trixie-slim AS base
+
+LABEL org.opencontainers.image.description="AccessSystem - Swindon Makerspace Access Control"
+
+# Install system Perl and essential apt packages
+RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
+ && apt-get -y install --no-install-recommends \
+ perl \
+ cpanminus \
+ build-essential \
+ # Database drivers
+ libdbd-sqlite3-perl \
+ libdbd-pg-perl \
+ libpq-dev \
+ # Libraries for CPAN XS modules
+ libexpat1-dev \
+ libxml2-dev \
+ zlib1g-dev \
+ # Core Perl modules from apt (faster than CPAN)
+ libalgorithm-diff-perl \
+ libalgorithm-diff-xs-perl \
+ libalgorithm-merge-perl \
+ libcgi-fast-perl \
+ libcgi-pm-perl \
+ libclass-accessor-perl \
+ libencode-locale-perl \
+ libfcgi-perl \
+ libfile-fcntllock-perl \
+ libhtml-parser-perl \
+ libhtml-tagset-perl \
+ libhttp-date-perl \
+ libhttp-message-perl \
+ libio-html-perl \
+ libio-string-perl \
+ liblocale-gettext-perl \
+ liblwp-mediatypes-perl \
+ libsub-name-perl \
+ libtext-charwidth-perl \
+ libtext-iconv-perl \
+ libtext-wrapi18n-perl \
+ libtimedate-perl \
+ liburi-perl \
+ libscalar-list-utils-perl \
+ libmoose-perl \
+ libjson-perl \
+ libdata-dump-perl \
+ libtry-tiny-perl \
+ libdatetime-perl \
+ libpath-class-perl \
+ libplack-perl \
+ # XML::Parser from apt (avoids build issues)
+ libxml-parser-perl \
+ # Crypt::DES from apt (fails to build from CPAN, needed by RapidApp)
+ libcrypt-des-perl \
+ # SSL/TLS support
+ libssl-dev \
+ libio-socket-ssl-perl \
+ libnet-ssleay-perl \
+ ca-certificates \
+ # For Carton
+ && cpanm -n Carton \
+ && rm -rf /var/lib/apt/lists/* /root/.cpanm
+
+WORKDIR /app
+
+# =============================================================================
+# DEPS STAGE - Install CPAN dependencies via Carton
+# =============================================================================
+FROM base AS deps
+
+# Copy dependency files first (for better layer caching)
+COPY cpanfile cpanfile.snapshot ./
+
+# Create vendor directory structure (for cached install)
+COPY vendor/ vendor/
+
+# Install dependencies using cached mode as per README
+RUN carton install --cached \
+ && rm -rf /root/.cpanm
+
+# =============================================================================
+# TEST STAGE - For running tests
+# =============================================================================
+FROM deps AS test
+
+# Copy application code
+COPY lib/ lib/
+COPY t/ t/
+COPY root/ root/
+COPY script/ script/
+COPY sql/ sql/
+
+# Copy test and example configs - test config used as local override
+COPY accesssystem_api.conf.example accesssystem_api.conf
+COPY accesssystem_api_test.conf accesssystem_api_local.conf
+
+# Copy psgi files
+COPY app.psgi accesssystem.psgi ./
+
+# Set environment for tests
+ENV CATALYST_HOME=/app
+ENV PERL5LIB=/app/local/lib/perl5:/app/lib
+
+# Default command runs tests
+CMD ["carton", "exec", "prove", "-I", "lib", "-I", "t/lib", "-r", "t/"]
+
+# =============================================================================
+# PRODUCTION STAGE - Slim production image
+# =============================================================================
+FROM deps AS production
+
+# Copy only what's needed for production
+COPY lib/ lib/
+COPY root/ root/
+COPY script/ script/
+COPY app.psgi accesssystem.psgi ./
+
+# Config files should be mounted at runtime
+# COPY accesssystem_api.conf accesssystem_api_local.conf ./
+
+# Set environment
+ENV CATALYST_HOME=/app
+ENV PERL5LIB=/app/local/lib/perl5:/app/lib
+
+# Expose default Catalyst port
+EXPOSE 3000
+
+# Default command runs the API server
+CMD ["carton", "exec", "perl", "script/accesssystem_api_server.pl", "--port", "3000", "--host", "0.0.0.0"]
diff --git a/accesssystem_api_test.conf b/accesssystem_api_test.conf
new file mode 100644
index 0000000..45e060c
--- /dev/null
+++ b/accesssystem_api_test.conf
@@ -0,0 +1,53 @@
+# Test configuration for AccessSystem API
+# Used when running tests in CI or locally
+
+
+
+ dsn dbi:SQLite:test_db.db
+
+
+
+# Dummy reCAPTCHA keys (will pass validation in non-production mode)
+
+ site_key 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
+ secret_key 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
+
+
+# Disable debug in test mode
+
+ ignore_extensions []
+
+
+# Test email settings - don't actually send
+
+ stash_key email
+
+ content_type text/plain
+ charset utf-8
+
+
+
+# Dummy OneAll settings
+
+ subdomain test
+ domain test.api.oneall.com
+ public_key test-public-key
+ private_key test-private-key
+
+
+# Cookie settings
+
+ name access_system_test
+ mac_secret test-cookie-secret-for-testing-only
+
+
+
+ namespace accesssystem
+
+
+# Dummy Sendinblue/Brevo settings
+
+ api-key dummy-test-api-key
+
+
+base_url http://localhost:3000/accesssystem/
diff --git a/lib/AccessSystem/Schema/Result/Person.pm b/lib/AccessSystem/Schema/Result/Person.pm
index 80a0783..f64a8b4 100644
--- a/lib/AccessSystem/Schema/Result/Person.pm
+++ b/lib/AccessSystem/Schema/Result/Person.pm
@@ -265,12 +265,16 @@ sub is_valid {
my $is_paid;
- if(!$self->parent) {
+ # Check parent_id column directly to avoid relationship resolution issues
+ # on newly created objects that haven't been stored yet
+ my $parent_id = $self->get_column('parent_id');
+ if(!$parent_id) {
$is_paid = $self->payments_rs->search({
paid_on_date => { '<=' => $date_str },
expires_on_date => { '>=' => $date_str },
})->count;
} else {
+ # Only call parent relationship if we have a parent_id
return $self->parent->is_valid;
}
@@ -305,7 +309,8 @@ sub bank_ref {
sub normal_dues {
my ($self) = @_;
- return 0 if $self->parent;
+ # Check parent_id column directly to avoid relationship resolution issues
+ return 0 if $self->get_column('parent_id');
if ($self->is_donor) {
return 0;
@@ -940,7 +945,8 @@ sub update_door_access {
# This entry should exist, but covid policy may have removed it..
my $door = $self->result_source->schema->the_door();
- my $door_allowed = $self->allowed->find_or_create({ tool_id => $door->id });
+ # is_admin required: allowed.is_admin has a NOT NULL constraint
+ my $door_allowed = $self->allowed->find_or_create({ tool_id => $door->id, is_admin => 0 });
$door_allowed->update({ pending_acceptance => 'false', accepted_on => DateTime->now()});
}
diff --git a/t/01app.t b/t/01app.t
index a824f04..25773a1 100644
--- a/t/01app.t
+++ b/t/01app.t
@@ -2,9 +2,32 @@
use strict;
use warnings;
use Test::More;
+use Cwd qw(getcwd);
+
+# Set CATALYST_HOME so config is loaded correctly
+$ENV{CATALYST_HOME} = getcwd();
+
+use lib 't/lib';
+use AccessSystem::Schema;
+use AccessSystem::Fixtures;
+
+# Use the same database that the config specifies
+# The test config uses dbi:SQLite:test_db.db
+my $testdb = 'test_db.db';
+unlink $testdb if -e $testdb; # Start fresh
+
+# Deploy schema and create fixtures
+my $schema = AccessSystem::Schema->connect("dbi:SQLite:$testdb");
+$schema->deploy();
+AccessSystem::Fixtures::create_tiers($schema);
use Catalyst::Test 'AccessSystem::API';
-ok( request('/register')->is_success, 'Request should succeed' );
+# Test that the app loads and responds to requests
+ok( request('/login')->is_success, 'Request to /login should succeed' );
+ok( request('/register')->is_success, 'Request to /register should succeed' );
+
+# Clean up
+unlink($testdb);
done_testing();
diff --git a/t/ResultSetPerson.t b/t/ResultSetPerson.t
index 198b5ce..e1347cd 100644
--- a/t/ResultSetPerson.t
+++ b/t/ResultSetPerson.t
@@ -82,8 +82,8 @@ my $schema = AccessSystem::Schema->connect("dbi:SQLite:$testdb");
my $comms_count = 0;
my $testee = AccessSystem::Fixtures::create_person($schema, payment => $payment_amount);
$testee->create_related('tokens', { id => '12345678', type => 'test token' });
- # The Door so that Result::Person::update_door_access works
- my $thing = $schema->resultset('Tool')->create({ name => 'The Door', assigned_ip => '10.0.0.1', requires_induction => 1, team => 'Who knows' });
+ # The Door so that Result::Person::update_door_access works (fixtures may have already created it)
+ my $thing = $schema->resultset('Tool')->find_or_create({ name => 'The Door', assigned_ip => '10.0.0.1', requires_induction => 1, team => 'Who knows' });
my $allowed = $testee->create_related('allowed', { tool => $thing, is_admin => 0});
$allowed->discard_changes();
$allowed->update({ pending_acceptance => 0 });
@@ -233,4 +233,3 @@ my $schema = AccessSystem::Schema->connect("dbi:SQLite:$testdb");
done_testing;
-
diff --git a/t/lib/AccessSystem/Fixtures.pm b/t/lib/AccessSystem/Fixtures.pm
new file mode 100644
index 0000000..4b358f2
--- /dev/null
+++ b/t/lib/AccessSystem/Fixtures.pm
@@ -0,0 +1,183 @@
+package AccessSystem::Fixtures;
+
+use strict;
+use warnings;
+
+use DateTime;
+
+=head1 NAME
+
+AccessSystem::Fixtures - Test fixture helpers for AccessSystem tests
+
+=head1 SYNOPSIS
+
+ use lib 't/lib';
+ use AccessSystem::Fixtures;
+
+ my $schema = AccessSystem::Schema->connect("dbi:SQLite:test.db");
+ $schema->deploy();
+
+ # Create membership tiers
+ AccessSystem::Fixtures::create_tiers($schema);
+
+ # Create a test person
+ my $person = AccessSystem::Fixtures::create_person($schema,
+ name => 'Test User',
+ dob => '1990-01',
+ );
+
+=head1 DESCRIPTION
+
+Test fixture helpers for unit tests. Provides functions to create
+test data in the database.
+
+=cut
+
+my $person_counter = 0;
+
+=head2 create_tiers($schema)
+
+Create the standard membership tiers used for testing.
+
+=cut
+
+sub create_tiers {
+ my ($schema) = @_;
+
+ my @tiers = (
+ {
+ id => 1,
+ name => 'Other Hackspace',
+ description => 'Member of another hackspace/makerspace',
+ price => 500, # ยฃ5
+ concessions_allowed => 0,
+ in_use => 1,
+ restrictions => '{}',
+ },
+ {
+ id => 2,
+ name => 'Standard',
+ description => 'Standard full membership',
+ price => 2500, # ยฃ25
+ concessions_allowed => 1,
+ in_use => 1,
+ restrictions => '{}',
+ },
+ {
+ id => 3,
+ name => 'Student',
+ description => 'Student membership (requires proof)',
+ price => 1250, # ยฃ12.50
+ concessions_allowed => 0,
+ in_use => 1,
+ restrictions => '{}',
+ },
+ {
+ id => 4,
+ name => 'Weekend',
+ description => 'Weekend access only',
+ price => 1500, # ยฃ15
+ concessions_allowed => 1,
+ in_use => 1,
+ restrictions => '{"times":[{"from":"6:00:00","to":"7:23:59"}]}',
+ },
+ {
+ id => 5,
+ name => "Men's Shed",
+ description => "Men's Shed membership",
+ price => 1000, # ยฃ10
+ concessions_allowed => 0,
+ in_use => 1,
+ restrictions => '{}',
+ },
+ {
+ id => 6,
+ name => 'Donation',
+ description => 'Donor only membership (no access)',
+ price => 0,
+ concessions_allowed => 0,
+ in_use => 1,
+ restrictions => '{}',
+ },
+ );
+
+ for my $tier_data (@tiers) {
+ $schema->resultset('Tier')->update_or_create($tier_data);
+ }
+
+ # Create 'The Door' tool - required by update_door_access()
+ $schema->resultset('Tool')->update_or_create({
+ id => 1,
+ name => 'The Door',
+ assigned_ip => '10.0.0.1',
+ requires_induction => 0,
+ team => 'Everyone',
+ });
+
+ return;
+}
+
+=head2 create_person($schema, %args)
+
+Create a test person. Returns the Person result object.
+
+Accepts optional arguments:
+ - name: Person name (defaults to 'Test Person N')
+ - email: Email address (defaults to 'test{N}@example.com')
+ - dob: Date of birth as 'YYYY-MM' (defaults to '1980-01')
+ - address: Address (defaults to '123 Test Street')
+ - c_rate: Concessionary rate override (e.g., 'student', 'legacy')
+ - tier_id: Tier ID (defaults to 2 = Standard)
+ - payment: Payment override (in pence)
+ - member_of_other_hackspace: Boolean (defaults to 0)
+
+=cut
+
+sub create_person {
+ my ($schema, %args) = @_;
+
+ $person_counter++;
+
+ my $person_data = {
+ name => $args{name} // "Test Person $person_counter",
+ email => $args{email} // "test$person_counter\@example.com",
+ dob => $args{dob} // '1980-01',
+ address => $args{address} // '123 Test Street, Testville, TE5 7ST',
+ tier_id => $args{tier_id} // 2, # Default to Standard tier
+ };
+
+ # Handle concessionary rate override
+ if (defined $args{c_rate}) {
+ $person_data->{concessionary_rate_override} = $args{c_rate};
+ }
+
+ # Handle payment override
+ if (defined $args{payment}) {
+ $person_data->{payment_override} = $args{payment};
+ }
+
+ my $person = $schema->resultset('Person')->create($person_data);
+
+ return $person;
+}
+
+=head2 reset_counter()
+
+Reset the person counter. Useful between test files.
+
+=cut
+
+sub reset_counter {
+ $person_counter = 0;
+ return;
+}
+
+1;
+
+__END__
+
+=head1 AUTHOR
+
+AccessSystem test fixtures
+
+=cut
diff --git a/test-deploy/README.md b/test-deploy/README.md
new file mode 100644
index 0000000..032adf7
--- /dev/null
+++ b/test-deploy/README.md
@@ -0,0 +1,32 @@
+# Local Production Test Environment
+
+This directory contains configuration and scripts to run the production Docker container locally with a full PostgreSQL database.
+
+## ๐ Quick Start
+
+1. **Run the start script:**
+ ```bash
+ ./start.sh
+ ```
+ This will:
+ - Build the production image
+ - Start containers (App + Postgres 17)
+ - Deploy the database schema
+ - Seed test data (Tiers + "The Door")
+
+2. **Access the App:**
+ - [http://localhost:3000/login](http://localhost:3000/login)
+ - [http://localhost:3000/register](http://localhost:3000/register)
+
+## ๐ Stop & Cleanup
+
+```bash
+docker compose down -v
+```
+
+## ๐ Structure
+
+- **`docker-compose.yaml`**: Orchestrates valid Prod container + Postgres DB.
+- **`config/accesssystem_api_local.conf`**: Test-specific config (connects to local DB, dummy keys).
+- **`seed_data.sql`**: Initial data needed for the app to function (Membership Tiers, Tools).
+- **`start.sh`**: Automates build, deploy, and seed steps.
diff --git a/test-deploy/docker-compose.yaml b/test-deploy/docker-compose.yaml
new file mode 100644
index 0000000..e725cff
--- /dev/null
+++ b/test-deploy/docker-compose.yaml
@@ -0,0 +1,34 @@
+services:
+ db:
+ image: postgres:17
+ environment:
+ POSTGRES_USER: access
+ POSTGRES_PASSWORD: accesstest
+ POSTGRES_DB: accesssystem
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: [ "CMD-SHELL", "pg_isready -U access -d accesssystem" ]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ app:
+ build:
+ context: ..
+ dockerfile: Dockerfile
+ target: production
+ ports:
+ - "3000:3000"
+ environment:
+ CATALYST_HOME: /app
+ volumes:
+ - ./config/accesssystem_api_local.conf:/app/accesssystem_api_local.conf:ro
+ - ../accesssystem_api.conf.example:/app/accesssystem_api.conf:ro
+ depends_on:
+ db:
+ condition: service_healthy
+ command: [ "carton", "exec", "perl", "script/accesssystem_api_server.pl", "--port", "3000", "--host", "0.0.0.0" ]
+
+volumes:
+ postgres_data:
diff --git a/test-deploy/seed_data.sql b/test-deploy/seed_data.sql
new file mode 100644
index 0000000..b2d1c91
--- /dev/null
+++ b/test-deploy/seed_data.sql
@@ -0,0 +1,10 @@
+INSERT INTO tiers (id, name, description, price, concessions_allowed, in_use, restrictions) VALUES
+(1, 'Other Hackspace', 'Member of another hackspace/makerspace', 500, false, true, '{}'),
+(2, 'Standard', 'Standard full membership', 2500, true, true, '{}'),
+(3, 'Student', 'Student membership (requires proof)', 1250, false, true, '{}'),
+(4, 'Weekend', 'Weekend access only', 1500, true, true, '{"times":[{"from":"6:00:00","to":"7:23:59"}]}'),
+(5, 'Men''s Shed', 'Men''s Shed membership', 1000, false, true, '{}'),
+(6, 'Donation', 'Donor only membership (no access)', 0, false, true, '{}');
+
+INSERT INTO tools (id, name, assigned_ip, requires_induction, team) VALUES
+('09637E38-F469-11F0-A94B-FD08D99F0D81', 'The Door', '10.0.0.1', false, 'Everyone');
diff --git a/test-deploy/start.sh b/test-deploy/start.sh
new file mode 100755
index 0000000..b63c572
--- /dev/null
+++ b/test-deploy/start.sh
@@ -0,0 +1,103 @@
+#!/bin/bash
+set -e
+
+# Ensure we're in the right directory
+cd "$(dirname "$0")"
+
+# Check for required tools
+if ! command -v docker &> /dev/null; then
+ echo "โ Error: 'docker' is not installed or not in PATH."
+ exit 1
+fi
+
+if ! docker compose version &> /dev/null; then
+ echo "โ Error: 'docker compose' is not available."
+ echo " Ensure you have a recent version of Docker Desktop installed."
+ exit 1
+fi
+
+echo "๐ Starting Production Container Test Environment..."
+
+# Create config if it doesn't exist
+if [ ! -f config/accesssystem_api_local.conf ]; then
+ echo "โ๏ธ Creating default test configuration..."
+ mkdir -p config
+ cat > config/accesssystem_api_local.conf <
+
+ dsn dbi:Pg:dbname=accesssystem;host=db
+ user access
+ password accesstest
+
+
+
+# Dummy reCAPTCHA keys (Google test keys)
+
+ site_key 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
+ secret_key 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
+
+
+# Dummy OneAll settings
+
+ subdomain test
+ domain test.api.oneall.com
+ public_key test-public-key
+ private_key test-private-key
+
+
+# Cookie settings
+
+ name access_system_test
+ mac_secret docker-test-cookie-secret
+
+
+
+ namespace accesssystem
+
+
+# Dummy Sendinblue/Brevo settings
+
+ api-key dummy-test-api-key
+
+
+base_url http://localhost:3000/accesssystem/
+EOL
+fi
+
+# Build (using parent context)
+echo "๐ฆ Building production image..."
+docker build --target production -t accesssystem:latest ..
+
+# Start containers
+echo "๐ Starting containers..."
+docker compose up -d
+
+# Wait for DB
+# Wait for DB to be healthy
+echo "โณ Waiting for Database to be ready..."
+RETRIES=30
+until docker compose exec -T db pg_isready -U access -d accesssystem > /dev/null 2>&1; do
+ ((RETRIES--))
+ if [ $RETRIES -le 0 ]; then
+ echo "โ Database failed to start in time."
+ exit 1
+ fi
+ echo "zzz... waiting for database ($RETRIES retries left)"
+ sleep 2
+done
+echo "โ
Database is up!"
+
+# Deploy Schema
+echo "๐ Deploying Schema (v18.0 PostgreSQL)..."
+docker compose exec -T db psql -U access -d accesssystem < ../sql/AccessSystem-Schema-18.0-PostgreSQL.sql
+
+# Seed Data
+echo "๐ฑ Seeding Data..."
+docker compose exec -T db psql -U access -d accesssystem < seed_data.sql
+
+echo "โ
Environment Ready!"
+echo "โก๏ธ Login: http://localhost:3000/login"
+echo "โก๏ธ Register: http://localhost:3000/register"
+echo ""
+echo "To stop: docker compose down -v"