Feature: Optional MySQL/MariaDB database driver An operator who cannot use a local SQLite file runs RenewCMS against MySQL or MariaDB by setting environment variables. SQLite remains the default and behaves exactly as before. Background: Given the database drivers are registered by name in database.drivers And the database is chosen by the DB_DRIVER environment variable And repositories, use cases and handlers are unchanged Scenario Outline: Driver selection Given DB_DRIVER is "" When the server starts Then the outcome is "" Examples: | value | outcome | | unset | the sqlite driver opens DB_FILE | | sqlite | the sqlite driver opens DB_FILE | | mysql | the mysql driver opens the DB_HOST connection | | oracle | startup fails with: unsupported database driver "oracle" | Scenario Outline: Missing MySQL variable Given DB_DRIVER is "mysql" And "" is not set When the environment is validated Then startup fails with "Environment variable is not set" Examples: | missing | | DB_HOST | | DB_USER | | DB_PASSWORD | | DB_NAME | Scenario: SQLite does not require MySQL variables Given DB_DRIVER is "sqlite" And DB_HOST, DB_USER, DB_PASSWORD and DB_NAME are not set When the environment is validated Then validation succeeds Scenario Outline: DSN built from variables Given DB_HOST "", DB_PORT "", DB_USER "app", DB_NAME "cms", DB_TLS "" When the MySQL DSN is built Then the DSN targets ":" and database "cms" And it contains parseTime=true, loc=UTC, charset=utf8mb4, clientFoundRows=true And it contains tls="" Examples: | host | port | expectedPort | tls | | db.local | | 3306 | false | | db.local | 3307 | 3307 | true | | mysql | 3306 | 3306 | skip-verify | Scenario: Invalid TLS option is rejected Given DB_DRIVER is "mysql" and DB_TLS is "maybe" When the settings are read Then startup fails naming DB_TLS and the accepted values Scenario Outline: Fail fast when the server cannot be used Given DB_DRIVER is "mysql" and "" When the server starts Then startup stops with "Unable to open the database" And the message names the host and port And the message does not contain the DB_PASSWORD value Examples: | fault | | nothing listens on DB_PORT | | the password is wrong | | the database does not exist | Scenario: Data directory with MySQL Given DB_DRIVER is "mysql" and DATA_DIR is "/data" Then update state, staging and backups live in "/data" And DB_FILE is ignored Scenario Outline: DataDir resolution Given DB_FILE "" and DATA_DIR "" Then Settings.DataDir() is "" Examples: | dbfile | datadir | result | | ./data/renewcms.db | | ./data | | /var/lib/cms/cms.db | | /var/lib/cms | | ./data/renewcms.db | /srv/x | /srv/x | Scenario: Connection pool Given DB_MAX_OPEN_CONNS 4, DB_MAX_IDLE_CONNS 2 and DB_CONN_MAX_LIFETIME_SECONDS 60 When the MySQL connection is opened Then the pool limits are 4 open, 2 idle and 60 seconds Scenario: Migration on MySQL Given an empty MySQL database When database.Migrate runs Then the tables articles, images, users, invites, api_keys and purge_settings exist And the unique indexes on users and api_keys exist And the first user lacking a role becomes "owner" and the others "admin" Scenario: SQLite schema is untouched by the model changes Given a SQLite file created by the models before this change, holding rows When database.Migrate runs Then the schema and every row are unchanged Scenario Outline: Repository behaviour is the same on both drivers Given the "" driver Then the repository contract "" holds Examples: | driver | case | | sqlite | create user and read it back with its id and timestamps | | mysql | create user and read it back with its id and timestamps | | sqlite | username "Bob" and "bob" are distinct users | | mysql | username "Bob" and "bob" are distinct users | | sqlite | duplicate email returns an error and stores nothing | | mysql | duplicate email returns an error and stores nothing | | sqlite | unknown id returns domain ErrUserNotFound | | mysql | unknown id returns domain ErrUserNotFound | | sqlite | accept invite twice: the second returns ErrInviteNotFound | | mysql | accept invite twice: the second returns ErrInviteNotFound | | sqlite | accept invite with a taken username leaves the invite unaccepted | | mysql | accept invite with a taken username leaves the invite unaccepted | | sqlite | renew invite with unchanged values still succeeds | | mysql | renew invite with unchanged values still succeeds | | sqlite | duplicate image hashes are grouped and counted | | mysql | duplicate image hashes are grouped and counted | | sqlite | media search treats % and _ literally | | mysql | media search treats % and _ literally | | sqlite | article body of 1 MB round-trips | | mysql | article body of 1 MB round-trips | | sqlite | zero reset expiration round-trips as a zero time | | mysql | zero reset expiration round-trips as a zero time | | sqlite | purge settings save, then SaveLastRun with identical values succeeds | | mysql | purge settings save, then SaveLastRun with identical values succeeds | Scenario Outline: Backup before an update Given the "" driver with a populated database When the pre-update backup runs Then a file "" exists in DATA_DIR/backups And only the 3 newest backups are kept And a failure returns an error, so the update download fails instead of claiming a backup Examples: | driver | pattern | | sqlite | renewcms-