commit 30/9
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-09-30 09:05:43 +02:00
parent 67dad461d9
commit 3dc7c5c649
75 changed files with 4123 additions and 107 deletions
+20
View File
@@ -0,0 +1,20 @@
<?php
use App\Models\User;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$db = DB::connection();
if (! $app->environment('testing') || $db->getDatabaseName() !== 'portal_services_test' || (string) $db->getConfig('port') !== '55441') {
throw new RuntimeException('Isolated QA required');
}
$user = User::firstOrCreate(['email' => 'mne-browser@example.test'], [
'name' => 'Synthetic MNE browser', 'password' => Hash::make('SyntheticMneOnly-2026!'),
]);
$user->forceFill(['email_verified_at' => now()])->save();
$db->table('sectors')->updateOrInsert(['slug' => 'wizard-browser-synthetic'], ['name' => 'Synthetic wizard sector', 'is_active' => true]);
echo "Synthetic isolated fixture ready\n";
+92
View File
@@ -0,0 +1,92 @@
// Isolated QA only. Synthetic account is prepared separately, never against the application DB.
import fs from 'node:fs';
const base = 'http://127.0.0.1:18027';
const target = await (await fetch('http://127.0.0.1:9224/json/new?about:blank', { method: 'PUT' })).json();
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise(resolve => ws.addEventListener('open', resolve, { once: true }));
let seq = 0;
const pending = new Map();
const errors = [];
ws.onmessage = event => {
const m = JSON.parse(event.data);
if (m.method === 'Runtime.exceptionThrown') errors.push(m.params.exceptionDetails.text);
if (m.id) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.reject(m.error) : p.resolve(m.result); }
};
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
const evaluate = async expression => {
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw Error('Browser expression failed');
return r.result?.value;
};
const wait = async expression => { for (let n = 0; n < 100; n++) { if (await evaluate(expression)) return; await new Promise(r => setTimeout(r, 100)); } throw Error('Browser condition timed out: ' + expression); };
const fill = async (selector, value) => {
await evaluate(`document.querySelector(${JSON.stringify(selector)}).focus()`);
await send('Input.insertText', { text: value });
};
const field = async (label, value) => {
const selector = `([...document.querySelectorAll('label')].find(l=>l.textContent.includes(${JSON.stringify(label)}))?.querySelector('input,textarea,select'))`;
await wait(`!!${selector}`);
await evaluate(`(()=>{const e=${selector}; e.focus(); if(e.tagName==='SELECT'){ e.value=${JSON.stringify(value)}; e.dispatchEvent(new Event('change',{bubbles:true})); }else {e.select();}})()`);
if (!await evaluate(`${selector}.tagName==='SELECT'`)) await send('Input.insertText', { text: value });
};
const step = async number => {
await wait(`![...document.querySelectorAll('ol button')][${number - 1}].disabled`);
await evaluate(`[...document.querySelectorAll('ol button')][${number - 1}].click()`);
await wait(`document.querySelector('h2')?.textContent.startsWith('${number}/8')`);
};
const observations = [];
try {
await send('Page.enable'); await send('Runtime.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 390, height: 900, deviceScaleFactor: 1, mobile: true });
await send('Page.navigate', { url: base + '/login' });
await wait(`document.readyState==='complete' && (location.pathname!='/login' || !!document.querySelector('input[type="password"]'))`);
if (await evaluate(`location.pathname==='/login'`)) {
await fill('input[type="email"]', 'mne-browser@example.test');
await fill('input[type="password"]', 'SyntheticMneOnly-2026!');
await evaluate(`document.querySelector('button[type="submit"]').click()`);
await wait(`!location.pathname.startsWith('/login')`);
}
await send('Page.navigate', { url: base + '/investidor/negocios' });
await wait(`document.body.innerText.includes('Preparar negócio')`);
await fill('main form input', 'Synthetic wizard browser');
await evaluate(`document.querySelector('main form button').click()`);
await wait(`document.querySelector('h2')?.textContent.startsWith('1/8')`);
await field('Tipo de proponente', 'person');
await field('Nome ou denominação', 'Synthetic owner');
await field('NUIT', 'SYNTHETIC');
await field('Email de contacto', 'synthetic@example.test');
await field('País', 'MZ');
await wait(`document.body.innerText.includes('Rascunho guardado no servidor')`);
await send('Page.reload');
await wait(`!![...document.querySelectorAll('input')].find(e=>e.value==='Synthetic owner')`);
observations.push({ scenario: 'autosave_and_reload', passed: true });
await step(2);
await field('Qualidade', 'holder'); await field('Titular declarado', 'Synthetic owner'); await field('Declaro legitimidade', 'yes');
await step(3);
await evaluate(`(()=>{const e=[...document.querySelectorAll('label')].find(l=>l.querySelector('span')?.textContent==='Sector')?.querySelector('select'); e.value=e.options[1].value;e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await field('Descrição reservada','Synthetic private description long enough for this browser-only review.');
await evaluate(`(()=>{const e=document.querySelector('input[type=date]');const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;setter.call(e,'2027-12-31');e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await step(5); await field('Localização declarada','Synthetic location, no coordinates');
await step(7); await field('Referência da fonte','Synthetic source for browser testing');
await step(8);
await wait(`document.body.innerText.includes('Rascunho guardado no servidor')`);
const code = await evaluate(`document.querySelector('main').innerText.match(/MNE-[0-9a-f-]{36}/)[0]`);
await field('Digite o código',code);
await evaluate(`document.querySelector('input[type=checkbox]').click()`);
for (const width of [390,1440]) {
await send('Emulation.setDeviceMetricsOverride',{width,height:900,deviceScaleFactor:1,mobile:width===390});
const fits = await evaluate(`document.documentElement.scrollWidth<=innerWidth+1`);
if (!fits) throw Error('Horizontal overflow');
observations.push({scenario:'responsive_summary',width,passed:true});
}
await evaluate(`[...document.querySelectorAll('button')].find(e=>e.textContent.includes('Confirmar e submeter')).click()`);
await wait(`document.body.innerText.includes('Submetido') && !document.querySelector('progress')`);
observations.push({scenario:'private_submission',passed:true});
if(errors.length) throw Error('Browser exceptions');
fs.writeFileSync('docs/investmoz/existing-business/wizard-browser-validation.json',JSON.stringify({passed:true,observations,errors},null,2)+'\n');
console.log(JSON.stringify({passed:true,observations,errors}));
} finally {
ws.close();
await fetch('http://127.0.0.1:9224/json/close/'+target.id);
}
+2 -71
View File
@@ -1,71 +1,2 @@
// Isolated QA only. Synthetic account is prepared separately, never against the application DB.
import fs from 'node:fs';
const base = 'http://127.0.0.1:18027';
const target = await (await fetch('http://127.0.0.1:9224/json/new?about:blank', { method: 'PUT' })).json();
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise(resolve => ws.addEventListener('open', resolve, { once: true }));
let seq = 0;
const pending = new Map();
const errors = [];
ws.onmessage = event => {
const m = JSON.parse(event.data);
if (m.method === 'Runtime.exceptionThrown') errors.push(m.params.exceptionDetails.text);
if (m.id) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.reject(m.error) : p.resolve(m.result); }
};
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
const evaluate = async expression => {
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw Error('Browser expression failed');
return r.result?.value;
};
const wait = async expression => { for (let n = 0; n < 100; n++) { if (await evaluate(expression)) return; await new Promise(r => setTimeout(r, 100)); } throw Error('Browser condition timed out: ' + expression); };
const fill = async (selector, value) => {
await evaluate(`document.querySelector(${JSON.stringify(selector)}).focus()`);
await send('Input.insertText', { text: value });
};
const observations = [];
try {
await send('Page.enable'); await send('Runtime.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 390, height: 900, deviceScaleFactor: 1, mobile: true });
await send('Page.navigate', { url: base + '/login' });
await wait(`document.readyState==='complete' && (location.pathname!='/login' || !!document.querySelector('input[type="password"]'))`);
if (await evaluate(`location.pathname==='/login'`)) {
await fill('input[type="email"]', 'mne-browser@example.test');
await fill('input[type="password"]', 'SyntheticMneOnly-2026!');
await evaluate(`document.querySelector('button[type="submit"]').click()`);
await wait(`!location.pathname.startsWith('/login')`);
}
await send('Page.navigate', { url: base + '/investidor/negocios' });
await wait(`!!document.querySelector('h1') && document.body.innerText.includes('Preparar neg')`);
await fill('main form input', 'Synthetic browser business');
await evaluate(`document.querySelector('main form button').click()`);
await wait(`location.pathname.startsWith('/investidor/negocios/') && !!document.querySelector('textarea')`);
await fill('fieldset textarea', 'Synthetic private description for review of a browser-only business.');
await fill('fieldset input[maxlength="2000"]', 'Synthetic declared reason');
await fill('fieldset input[maxlength="2000"]:last-of-type', 'Synthetic source evidence');
// Find source input by its wrapping label; field labels are accessible label elements.
await evaluate(`(()=>{const labels=[...document.querySelectorAll('label')];const field=labels.find(l=>l.textContent.includes('Referência da fonte'))?.querySelector('input'); if(field) field.focus();})()`);
await send('Input.insertText', { text: 'Synthetic source evidence' });
await evaluate(`(()=>{const el=document.querySelector('input[type="date"]'); const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set; setter.call(el,'2027-12-31');el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await fill('main form > label textarea', 'Synthetic save for browser QA');
await evaluate(`document.querySelector('main form button').click()`);
await wait(`document.body.innerText.includes('versão 2')`);
observations.push({ scenario: 'private_create_and_save', passed: true });
await fill('main form > label textarea', 'Synthetic submission for browser QA');
await evaluate(`document.querySelector('input[type="checkbox"]').click()`);
await evaluate(`[...document.querySelectorAll('button')].find(b=>b.textContent==='Submeter para análise').click()`);
await wait(`document.body.innerText.includes('versão 3') && document.body.innerText.includes('Submetido')`);
observations.push({ scenario: 'private_submit_with_review_consent', passed: true });
for (const width of [390, 1440]) {
await send('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 600 });
const result = await evaluate(`(()=>{const a=[...document.querySelectorAll('a')].find(a=>getComputedStyle(a).position==='fixed'&&a.href.endsWith('/apoio'));const b=a?.getBoundingClientRect();return {width:innerWidth,breadcrumb:!!document.querySelector('nav[aria-label="Caminho de navegação"]'),supportVisible:!!b&&b.bottom<=innerHeight&&b.left>=0,overflow:document.documentElement.scrollWidth>innerWidth+1};})()`);
if (!result.breadcrumb || !result.supportVisible || result.overflow) throw Error('Layout failure');
observations.push(result);
}
if (errors.length) throw Error('JavaScript errors observed');
fs.writeFileSync('docs/investmoz/existing-business/browser-validation.json', JSON.stringify({ date: new Date().toISOString(), scope: 'Synthetic private create/save and responsive layout in isolated QA; institutional review covered by integration tests', observations, errors }, null, 2) + '\n');
console.log(JSON.stringify({ passed: observations.length, javascript_errors: errors.length }));
} catch (error) {
console.log(await evaluate(`({path:location.pathname,title:document.title,body:document.body.innerText.slice(0,1200)})`));
throw error;
} finally { await send('Page.close'); ws.close(); }
// Compatibility entry point: prepare tools/business-wizard-browser-seed.php in isolated QA first.
import './business-wizard-browser.mjs';
+13 -1
View File
@@ -46,6 +46,18 @@ document.paths['/listings/{id}/verifications'] = { post: operation('Record indep
document.paths['/listings/{id}'].get.responses['200'].content['application/json'].schema.properties.verifications = {
type: 'array', items: { ...verification, properties: { ...verification.properties, effective_outcome: { enum: ['confirmed', 'rejected', 'inconclusive', 'revoked', 'stale', 'expired'] }, content_version: { type: 'integer' }, created_at: text } },
};
document.info.version = '1.1.0';
const formSchema = JSON.parse(fs.readFileSync('docs/investmoz/existing-business/form-schema.json', 'utf8'));
for (const [key, field] of Object.entries(formSchema)) {
payload.properties[key] = { type: key === 'title' ? 'string' : ['string', 'null'], maxLength: field.max,
...(field.options ? { enum: [...Object.keys(field.options), null] } : {}),
...(field.type === 'date' ? { format: 'date' } : {}), description: field.en };
}
payload.properties.wizard_version = { type: ['string', 'null'], enum: ['1', null] };
payload.properties.wizard_step = { type: ['string', 'null'], pattern: '^[1-8]$' };
input.properties.sector_id = { type: ['integer', 'null'], description: 'Existing active public.sectors identifier; required on wizard submission.' };
document.paths['/listings/{id}'].put.requestBody.content['application/json'].schema.properties.sector_id = input.properties.sector_id;
document.components.schemas.Business.properties.sector_id = input.properties.sector_id;
document.paths['/listings/{id}/transitions'].post.requestBody.content['application/json'].schema.properties.confirmation_code = { type: ['string', 'null'], description: 'Exact business code required for wizard_version=1 submission, in addition to internal review consent.' };
document.info.version = '1.2.0';
fs.writeFileSync('docs/investmoz/existing-business/openapi.json', JSON.stringify(document, null, 2) + '\n');
console.log('OpenAPI written: 9 operations, private session API.');
+8
View File
@@ -0,0 +1,8 @@
<?php
use App\Services\BusinessFormSchema;
require __DIR__.'/../vendor/autoload.php';
file_put_contents(__DIR__.'/../docs/investmoz/existing-business/form-schema.json', json_encode(
BusinessFormSchema::fields(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
).PHP_EOL);
+38
View File
@@ -0,0 +1,38 @@
<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$report = DB::transaction(function (): array {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$tables = DB::select("SELECT table_schema,table_name,table_type FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog','information_schema') ORDER BY 1,2");
$counts = [];
foreach ($tables as $table) {
if ($table->table_type === 'BASE TABLE') {
$counts[$table->table_schema.'.'.$table->table_name] = DB::table($table->table_schema.'.'.$table->table_name)->count();
}
}
return [
'captured_at' => gmdate('c'),
'server' => DB::selectOne('SHOW server_version')->server_version,
'extensions' => DB::select('SELECT extname,extversion FROM pg_extension ORDER BY extname'),
'tables' => $tables,
'columns' => DB::select("SELECT table_schema,table_name,column_name,data_type,udt_name,is_nullable,column_default FROM information_schema.columns WHERE table_schema NOT IN ('pg_catalog','information_schema') ORDER BY table_schema,table_name,ordinal_position"),
'indexes' => DB::select("SELECT schemaname,tablename,indexname,indexdef FROM pg_indexes WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY 1,2,3"),
'constraints' => DB::select("SELECT n.nspname AS schema,c.relname AS table_name,k.conname,k.contype,k.convalidated,pg_get_constraintdef(k.oid) AS definition FROM pg_constraint k JOIN pg_class c ON c.oid=k.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname NOT IN ('pg_catalog','information_schema') ORDER BY 1,2,3"),
'views' => DB::select("SELECT schemaname,viewname,definition FROM pg_views WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY 1,2"),
'triggers' => DB::select("SELECT n.nspname AS schema,c.relname AS table_name,t.tgname,pg_get_triggerdef(t.oid) AS definition FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE NOT t.tgisinternal AND n.nspname NOT IN ('pg_catalog','information_schema') ORDER BY 1,2,3"),
'row_counts' => $counts,
];
});
$directory = storage_path('app/private/reference-reconciliation-'.gmdate('Ymd-His'));
if (! mkdir($directory, 0700, true) && ! is_dir($directory)) {
throw new RuntimeException('Cannot create private evidence directory.');
}
$path = $directory.'/inventory.json';
file_put_contents($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
echo json_encode(['path' => $path, 'sha256' => hash_file('sha256', $path), 'tables' => count($report['row_counts']), 'columns' => count($report['columns']), 'indexes' => count($report['indexes']), 'constraints' => count($report['constraints'])], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
+89
View File
@@ -0,0 +1,89 @@
"""Convert the supplied baseline to versioned, master-linked SQL. No database writes."""
import re
from pathlib import Path
root = Path(__file__).resolve().parents[1]
source = Path(r'C:/Users/macbook/.codex/attachments/8528a9d2-b934-4bb3-a132-a18b7e13414d/Pasted text.txt')
text = source.read_text(encoding='utf-8-sig')
parts = re.split(r'5\.10\.[1-6]\. [^\r\n]+\r?\n', text)[1:]
assert len(parts) == 6
parts[-1] = parts[-1].split('COMMIT;')[0]
extensions = {
'qualification_documents': ('qualification_document_details', 'documents', {'storage_key','original_filename','mime_type','sha256_checksum'}),
'consents': ('consent_details', 'consents', {'purpose_code','status','granted_at','revoked_at'}),
'skills': ('skill_details', 'skills', {'code','name_pt'}),
'occupations': ('occupation_details', 'occupations', {'code','name_pt'}),
'education_programs': ('program_details', 'courses', {'institution_master_id','code','title_pt','status'}),
'human_resource_needs': ('human_resource_need_details', 'labour_demands', {'occupation_id','project_master_id','location_master_id','qualification_level','estimated_quantity','needed_from','needed_until','source_name','status'}),
'educational_opportunities': ('educational_opportunity_details', 'education_investment_proposals', {'title_pt','description_pt','indicative_value','currency','status'}),
}
renames = {k:v[0] for k,v in extensions.items()}
user_columns = ['user_master_id','created_by','updated_by','approved_by','validated_by','verified_by','uploaded_by','reviewed_by','responsible_user_id','actor_user_id','institution_operator_user_id']
masters = {**{k:('users','bigint') for k in user_columns},
'sector_master_id':('sectors','bigint'), 'company_master_id':('organizations','uuid'),
'organisation_master_id':('organizations','uuid'), 'institution_master_id':('training_providers','uuid'),
'program_master_id':('courses','uuid'), 'project_master_id':('projects','uuid'),
'cluster_master_id':('economic_clusters','uuid'), 'corridor_master_id':('development_corridors','uuid'),
'zee_master_id':('investment_zones','uuid'), 'zfi_master_id':('investment_zones','uuid'), 'location_master_id':('geo_locations','uuid')}
out = root/'database/sql/skills-education'
out.mkdir(parents=True,exist_ok=True)
all_tables=[]
for number, sql in enumerate(parts,1):
for original,(new,master,omit) in extensions.items():
pattern = rf'CREATE TABLE skills_edu\.{original} \(\n(.*?)\n\);'
def adapt(match):
lines=match.group(1).splitlines()
result=[]
for line in lines:
field=line.strip().split()[0]
if field in omit: continue
if original=='human_resource_needs' and 'CHECK (needed_until' in line: continue
if original=='education_programs' and 'UNIQUE (institution_master_id' in line: continue
if original=='qualification_documents' and 'UNIQUE (storage_key)' in line: continue
if original=='consents' and ('CHECK (expires_at' in line or 'CHECK (revoked_at' in line): continue
if field=='id': line=f' id uuid PRIMARY KEY REFERENCES public.{master}(id) ON DELETE RESTRICT,'
result.append(line)
return 'CREATE TABLE skills_edu.'+new+' (\n'+'\n'.join(result).rstrip().rstrip(',')+'\n);'
sql=re.sub(pattern,adapt,sql,flags=re.S)
# Foreign keys for shared catalogues always point at the existing masters.
for original,master in [('skills','skills'),('occupations','occupations'),('education_programs','courses')]:
sql=sql.replace(f'REFERENCES skills_edu.{original}(id)',f'REFERENCES public.{master}(id)')
for original,new in renames.items():
sql=re.sub(rf'skills_edu\.{original}\b',f'skills_edu.{new}',sql)
sql=re.sub(r'^CREATE INDEX idx_(hr_needs_search|hr_needs_dates|programs_institution_status|opportunities_context).*?;\n','',sql,flags=re.M)
sql=re.sub(r'^CREATE INDEX idx_(documents_checksum|consents_profile_purpose).*?;\n','',sql,flags=re.M)
sql=re.sub(r'^COMMENT ON COLUMN skills_edu\.qualification_document_details\.storage_key.*?;\n','',sql,flags=re.M)
for column,(master,typ) in masters.items():
sql=re.sub(rf'\b{column} uuid\b', f'{column} {typ}', sql)
tables=re.findall(r'CREATE TABLE skills_edu\.(\w+)',sql)
all_tables += tables
for table in tables:
body=re.search(rf'CREATE TABLE skills_edu\.{table} \((.*?)\n\);',sql,re.S).group(1)
for column,(master,typ) in masters.items():
if re.search(rf'^ {column} ',body,re.M):
sql+=f'\nALTER TABLE skills_edu.{table} ADD FOREIGN KEY ({column}) REFERENCES public.{master}(id) ON DELETE RESTRICT;\n'
if number == 3:
sql += '''
CREATE FUNCTION skills_edu.validate_consent_dates() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog AS $$
DECLARE granted timestamptz; subject text;
BEGIN
SELECT granted_at,subject_ref INTO granted,subject FROM public.consents WHERE id=NEW.id;
IF subject IS DISTINCT FROM NEW.profile_id::text OR granted IS NULL OR (NEW.expires_at IS NOT NULL AND NEW.expires_at<=granted) THEN
RAISE EXCEPTION 'Consent master profile or validity mismatch' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER validate_consent_master BEFORE INSERT OR UPDATE ON skills_edu.consent_details
FOR EACH ROW EXECUTE FUNCTION skills_edu.validate_consent_dates();
'''
(out/f'{number:02d}.sql').write_text(sql,encoding='utf-8')
migration=f'''<?php
return new class extends \\App\\Support\\SkillsEducationMigration
{{
protected int $block = {number};
protected array $tables = {str(tables).replace('[','[').replace(']',']')};
}};
'''
(root/f'database/migrations/2026_09_30_10{number:02d}00_skills_education_{number}.php').write_text(migration,encoding='utf-8')
print(f'Prepared {len(parts)} versioned blocks, {len(all_tables)} tables; existing catalogues remain masters.')
+126
View File
@@ -0,0 +1,126 @@
<?php
use App\Models\User;
use App\Services\ExistingBusinessDrafts;
use App\Services\Mne\BusinessRepository;
use App\Services\Mne\CompatibilityBackfill;
use App\Services\Mne\IdentityResolver;
use App\Services\Mne\SectorResolver;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$db = DB::connection();
if (! $app->environment('testing') || $db->getDatabaseName() !== 'portal_services_test' || (string) $db->getConfig('port') !== '55441') {
throw new RuntimeException('Isolated QA required');
}
if ($db->selectOne("SELECT count(*) AS n FROM pg_namespace WHERE nspname='mne'")->n) {
throw new RuntimeException('QA schema mne must be absent');
}
$run = function (string $file) use ($db): void {
$sql = preg_replace('/^BEGIN;\s*$/m', '', file_get_contents(base_path('migrations/'.$file)));
$db->unprepared(preg_replace('/COMMIT;\s*$/', '', $sql));
};
$check = function (bool $condition, string $message): void {
if (! $condition) {
throw new RuntimeException($message);
}
};
$reject = function (callable $operation, string $state) use ($db, $check): void {
$db->statement('SAVEPOINT rejection');
$rejected = false;
try {
$operation();
} catch (QueryException $e) {
$rejected = $e->getCode() === $state;
}
$db->statement('ROLLBACK TO SAVEPOINT rejection');
$check($rejected, 'Expected database rejection '.$state);
};
$db->beginTransaction();
try {
foreach (['001_mne_schema.sql', '002_mne_existing_territories.sql', '003_mne_integrity.sql', '004_mne_compatibility.sql'] as $file) {
$run($file);
}
$run('004_mne_compatibility.sql');
$run('004_mne_compatibility_down.sql');
$run('004_mne_compatibility.sql');
$user = User::factory()->create();
config(['existing-business.enabled' => true]);
$business = app(ExistingBusinessDrafts::class)->save($user, [
'modality' => 'company_sale', 'classification' => 'confidential', 'reason' => 'Synthetic compatibility test',
'payload' => ['title' => 'Synthetic private business'],
]);
$stranger = User::factory()->create();
$loaded = app(BusinessRepository::class)->find($business->id);
$check(Gate::forUser($user)->allows('view', $loaded), 'Owner lost access');
$check(Gate::forUser($stranger)->denies('view', $loaded), 'Unauthorized access');
$sector = $db->table('public.sectors')->insertGetId(['name' => 'Synthetic compatibility', 'slug' => 'synthetic-compatibility', 'is_active' => true]);
$backfill = app(CompatibilityBackfill::class);
$dryStatus = Artisan::call('mne:backfill-compatibility', ['--batch' => 2, '--dry-run' => true]);
$check($dryStatus === 0, 'Dry run failed');
foreach (['identity_links', 'sector_links', 'business_links', 'compatibility_checkpoints', 'compatibility_rejections'] as $table) {
$check($db->table('mne.'.$table)->count() === 0, 'Dry run persisted data: '.$table);
}
$first = $backfill->run(2);
$links = $db->table('mne.identity_links')->orderBy('user_id')->get()->toJson();
$second = $backfill->run(2);
$check($first === $second, 'Backfill counts changed');
$check($links === $db->table('mne.identity_links')->orderBy('user_id')->get()->toJson(), 'Backfill not idempotent');
$check(app(SectorResolver::class)->resolve($sector) !== null, 'Sector not resolved');
$check(app(SectorResolver::class)->resolve(2147483647) === null, 'Unknown sector invented');
$row = (array) $db->table('mne.identity_links')->where('user_id', $user->id)->first();
unset($row['id']);
$reject(fn () => $db->table('mne.identity_links')->insert($row), '23505');
$row['user_id'] = 9223372036854775807;
$reject(fn () => $db->table('mne.identity_links')->insert($row), '23503');
$reject(fn () => $db->table('mne.identity_links')->where('user_id', $user->id)->update(['status' => 'active']), '23514');
$party = $db->selectOne("INSERT INTO mne.parties(party_type,display_name) VALUES ('person','Synthetic party') RETURNING id")->id;
$db->table('mne.identity_links')->where('user_id', $user->id)->update(['party_id' => $party, 'status' => 'active']);
$check(app(IdentityResolver::class)->resolve($user->id) === $party, 'Identity link not resolved');
$reject(fn () => $db->table('mne.identity_links')->where('user_id', $stranger->id)->update(['party_id' => $party]), '23505');
$db->table('mne.identity_links')->where('user_id', $user->id)->update(['status' => 'revoked']);
$check(app(IdentityResolver::class)->resolve($user->id) === null, 'Revoked identity resolved');
$reject(fn () => $run('004_mne_compatibility_down.sql'), 'P0001');
$check($db->table('mne.existing_businesses')->count() === 0, 'Backfill copied business data');
$check($db->table('mne.parties')->count() === 1, 'Backfill invented identities');
$db->statement('SAVEPOINT schema_drift');
$db->statement('ALTER TABLE mne.business_links ADD COLUMN unexpected text');
$reject(fn () => $run('004_mne_compatibility.sql'), 'P0001');
$db->statement('ROLLBACK TO SAVEPOINT schema_drift');
$db->table('public.sectors')->where('id', $sector)->update(['slug' => 'changed-compatibility']);
$check(app(SectorResolver::class)->resolve($sector) === null, 'Stale code resolved');
$backfill->run(2);
$check($db->table('mne.sector_links')->where('sector_id', $sector)->value('status') === 'pending', 'Changed source not invalidated');
$run('005_mne_compatibility_guards.sql');
$run('005_mne_compatibility_guards.sql');
$reject(fn () => $db->table('mne.compatibility_controls')->where('capability', 'canonical_read')
->update(['enabled' => true, 'evidence_reference' => 'Synthetic activation attempt']), '23514');
$reject(fn () => $db->table('mne.compatibility_controls')->where('capability', 'canonical_read')->update(['percentage' => 1]), '23514');
$run('005_mne_compatibility_guards_down.sql');
$run('005_mne_compatibility_guards.sql');
// Transactional test role: cannot own objects, create schemas, or disable protections.
$role = 'mne_runtime_test_'.getmypid();
$db->statement('CREATE ROLE '.$role.' NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS');
$db->statement('GRANT USAGE ON SCHEMA mne TO '.$role);
$db->statement('GRANT SELECT, INSERT ON mne.audit_events, mne.business_status_history, mne.document_versions, mne.negotiation_events TO '.$role);
$db->statement('SET LOCAL ROLE '.$role);
$check(! $db->selectOne('SELECT rolsuper FROM pg_roles WHERE rolname=current_user')->rolsuper, 'Runtime test is superuser');
$db->statement("INSERT INTO mne.audit_events(action,subject_type,subject_id,correlation_id) VALUES ('synthetic','test',gen_random_uuid(),gen_random_uuid())");
foreach (['audit_events', 'business_status_history', 'document_versions', 'negotiation_events'] as $table) {
foreach (["UPDATE mne.$table SET id=id", "DELETE FROM mne.$table", "TRUNCATE mne.$table"] as $sql) {
$reject(fn () => $db->statement($sql), '42501');
}
}
$reject(fn () => $db->statement('ALTER TABLE mne.audit_events DISABLE TRIGGER ALL'), '42501');
$reject(fn () => $db->statement('CREATE TABLE mne.forbidden_runtime_table(id integer)'), '42501');
$db->statement('RESET ROLE');
echo json_encode(['passed' => true, 'dry_run_rolled_back' => true, '005_repeated_and_reversed' => true, 'canonical_activation_rejected' => true, 'up_twice' => true, 'down_reapply' => true, 'backfill_replay' => true, 'uniqueness_fk_activation_down_guards' => true, 'counts' => $first]).PHP_EOL;
} finally {
$db->rollBack();
}
+78
View File
@@ -0,0 +1,78 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$db = Illuminate\Support\Facades\DB::connection();
if (! $app->environment('testing') || $db->getDatabaseName() !== 'portal_services_test' || (string) $db->getConfig('port') !== '55441') {
throw new RuntimeException('Isolated QA required');
}
if ($db->selectOne("SELECT count(*) AS n FROM pg_namespace WHERE nspname='mne'")->n) {
throw new RuntimeException('QA schema mne must be absent');
}
$run = function (string $file) use ($db): void {
$sql = file_get_contents(base_path('migrations/'.$file));
$sql = preg_replace('/^BEGIN;\s*$/m', '', $sql);
$db->unprepared(preg_replace('/COMMIT;\s*$/', '', $sql));
};
$indexes = fn () => (int) $db->selectOne("SELECT count(*) AS n FROM pg_indexes WHERE schemaname='mne'")->n;
$db->beginTransaction();
try {
$run('001_mne_schema.sql');
$run('002_mne_existing_territories.sql');
$before = $indexes();
$run('003_mne_integrity.sql');
$after = $indexes();
$missing = $db->selectOne(<<<'SQL'
SELECT count(*) AS n FROM pg_constraint c
JOIN pg_namespace n ON n.oid=c.connamespace
WHERE n.nspname='mne' AND c.contype='f' AND NOT EXISTS (
SELECT 1 FROM pg_index i JOIN pg_class ix ON ix.oid=i.indexrelid
JOIN pg_am am ON am.oid=ix.relam
WHERE i.indrelid=c.conrelid AND i.indisvalid AND i.indisready
AND i.indpred IS NULL AND am.amname='btree' AND i.indnkeyatts >= cardinality(c.conkey)
AND ARRAY(SELECT x FROM unnest(i.indkey::smallint[]) WITH ORDINALITY u(x,p)
WHERE p <= cardinality(c.conkey) ORDER BY p)=c.conkey
)
SQL);
if ((int) $missing->n !== 0) {
throw new RuntimeException('Foreign keys without index coverage');
}
$run('003_mne_integrity.sql');
if ($indexes() !== $after) {
throw new RuntimeException('Duplicate indexes');
}
$db->insert("INSERT INTO mne.audit_events(action,subject_type,subject_id,correlation_id) VALUES ('qa','synthetic',gen_random_uuid(),gen_random_uuid())");
$rejections = 0;
foreach (['audit_events', 'business_status_history', 'document_versions', 'negotiation_events'] as $table) {
foreach (["UPDATE mne.$table SET id=id", "DELETE FROM mne.$table", "TRUNCATE mne.$table"] as $sql) {
$db->statement('SAVEPOINT mutation');
$rejected = false;
try {
$db->unprepared($sql);
} catch (Illuminate\Database\QueryException $e) {
$rejected = $e->getCode() === '23514';
}
$db->statement('ROLLBACK TO SAVEPOINT mutation');
if (! $rejected) {
throw new RuntimeException('Mutation was not rejected by evidence guard');
}
$rejections++;
}
}
$run('003_mne_integrity_down.sql');
if ($indexes() !== $before) {
throw new RuntimeException('DOWN changed original indexes');
}
$db->delete('DELETE FROM mne.audit_events');
$run('003_mne_integrity.sql');
if ($indexes() !== $after) {
throw new RuntimeException('Reapply failed');
}
echo json_encode(['passed' => true, 'original_indexes' => $before, 'added_indexes' => $after - $before, 'mutation_rejections' => $rejections, 'idempotency_and_down' => true]).PHP_EOL;
} finally {
$db->rollBack();
}
if ($db->selectOne("SELECT count(*) AS n FROM pg_namespace WHERE nspname='mne'")->n) {
throw new RuntimeException('QA cleanup failed');
}
+98
View File
@@ -0,0 +1,98 @@
<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$config = DB::connection()->getConfig();
if (! app()->environment('testing') || $config['database'] !== 'portal_services_test' || (string) $config['port'] !== '55441' || ! in_array($config['host'], ['127.0.0.1', 'localhost'], true)) {
throw new RuntimeException('Requires isolated portal_services_test on localhost:55441.');
}
$migration = require database_path('migrations/2026_09_30_110000_project_dependency_integrity.php');
$assertions = 0;
$check = function (bool $condition) use (&$assertions): void {
if (! $condition) {
throw new RuntimeException('Dependency integrity assertion failed: '.($assertions + 1));
}
$assertions++;
};
$ids = array_map(fn () => (string) Str::uuid(), range(1, 3));
$link = fn (int $a, int $b, string $kind = 'dependency'): array => ['anchor_project_id' => $ids[$a], 'complementary_project_id' => $ids[$b], 'relation_type' => $kind];
$reject = function (callable $write) use ($check): void {
try {
$write();
} catch (QueryException $e) {
$check($e->getCode() === '23514');
return;
}
throw new RuntimeException('Cycle was accepted');
};
try {
foreach ($ids as $id) {
DB::table('projects')->insert(['id' => $id, 'code' => 'test-dependency-'.$id, 'name' => 'Synthetic dependency test', 'status' => 'draft']);
}
DB::table('complementary_project_links')->insert($link(0, 1));
$reject(fn () => DB::table('complementary_project_links')->insert($link(1, 0)));
DB::table('complementary_project_links')->insert($link(1, 2));
$reject(fn () => DB::table('complementary_project_links')->insert($link(2, 0)));
$reject(fn () => DB::table('complementary_project_links')->insert($link(0, 0)));
// Non-dependency relations may form a network; do not impose DAG semantics on them.
DB::table('complementary_project_links')->insert($link(2, 0, 'supplier'));
$reject(fn () => DB::table('complementary_project_links')->where('anchor_project_id', $ids[2])->update(['relation_type' => 'dependency']));
DB::table('complementary_project_links')->where('anchor_project_id', $ids[1])->update(['deleted_at' => now()]);
DB::table('complementary_project_links')->insert($link(2, 0));
$reject(fn () => DB::table('complementary_project_links')->where('anchor_project_id', $ids[1])->update(['deleted_at' => null]));
DB::table('complementary_project_links')->whereIn('anchor_project_id', $ids)->delete();
// Two real sessions: B→A waits for A→B, then must reject the resulting cycle.
$parts = [];
foreach (['host' => 'host', 'port' => 'port', 'dbname' => 'database', 'user' => 'username', 'password' => 'password'] as $key => $name) {
$parts[] = $key."='".str_replace(['\\', "'"], ['\\\\', "\\'"], (string) ($config[$name] ?? ''))."'";
}
$second = pg_connect(implode(' ', $parts), PGSQL_CONNECT_FORCE_NEW);
if ($second === false) {
throw new RuntimeException('Cannot connect second isolated session');
}
pg_query($second, "SET statement_timeout='10s'");
DB::beginTransaction();
DB::table('complementary_project_links')->insert($link(0, 1));
pg_send_query_params($second, "INSERT INTO public.complementary_project_links(anchor_project_id,complementary_project_id,relation_type) VALUES($1,$2,'dependency')", [$ids[1], $ids[0]]);
// Confirm the second statement is waiting on our advisory lock, not merely unscheduled.
$waiting = false;
for ($attempt = 0; $attempt < 100; $attempt++) {
$state = DB::selectOne('SELECT wait_event FROM pg_stat_activity WHERE pid=?', [pg_get_pid($second)]);
if (($state->wait_event ?? null) === 'advisory') {
$waiting = true;
break;
}
usleep(20000);
DB::statement('SELECT pg_stat_clear_snapshot()');
}
$check($waiting);
DB::commit();
$result = pg_get_result($second);
$check($result !== false && pg_result_error_field($result, PGSQL_DIAG_SQLSTATE) === '23514');
pg_close($second);
$check(DB::table('complementary_project_links')->whereIn('anchor_project_id', $ids)->count() === 1);
DB::table('complementary_project_links')->whereIn('anchor_project_id', $ids)->delete();
// Non-destructive DOWN/UP: schema reversibility without dropping any master or row.
DB::transaction(function () use ($migration, $check): void {
$migration->down();
$check(DB::selectOne("SELECT to_regprocedure('public.check_project_dependency_graph()') AS id")->id === null);
$migration->up();
$check(DB::selectOne("SELECT to_regprocedure('public.check_project_dependency_graph()') AS id")->id !== null);
});
echo json_encode(['passed' => true, 'assertions' => $assertions, 'two_session_cycle_rejected' => true, 'rollback_reapplication' => true], JSON_PRETTY_PRINT).PHP_EOL;
} finally {
while (DB::transactionLevel() > 0) {
DB::rollBack();
}
DB::table('complementary_project_links')->whereIn('anchor_project_id', $ids)->delete();
DB::table('projects')->whereIn('id', $ids)->delete();
}
+37
View File
@@ -0,0 +1,37 @@
<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$db = DB::connection();
$baseline = isset($argv[1]) ? json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR) : null;
$report = $db->transaction(function () use ($db, $baseline): array {
$db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$counts = [];
foreach ($db->select("SELECT tablename FROM pg_tables WHERE schemaname='skills_edu' ORDER BY tablename") as $table) {
$counts[$table->tablename] = $db->table('skills_edu.'.$table->tablename)->count();
}
$differences = [];
foreach ($baseline['row_counts'] ?? [] as $table => $before) {
$expected = $before + ($table === 'public.migrations' ? 6 : 0);
$actual = $db->table($table)->count();
if ($actual !== $expected) {
$differences[$table] = ['expected' => $expected, 'actual' => $actual];
}
}
return ['server' => $db->selectOne('SHOW server_version')->server_version,
'baseline_tables_checked' => count($baseline['row_counts'] ?? []), 'baseline_differences' => $differences,
'tables' => $counts, 'table_count' => count($counts),
'foreign_keys' => (int) $db->selectOne("SELECT count(*) AS n FROM pg_constraint c JOIN pg_namespace ns ON ns.oid=c.connamespace WHERE ns.nspname='skills_edu' AND c.contype='f'")->n,
'indexes' => $db->table('pg_indexes')->where('schemaname', 'skills_edu')->count(),
'registered_migrations' => $db->table('migrations')->where('migration', 'like', '2026_09_30_%_skills_education_%')->count(),
'sql_sha256' => array_combine(array_map('basename', glob(database_path('sql/skills-education/*.sql'))), array_map(fn ($file) => hash_file('sha256', $file), glob(database_path('sql/skills-education/*.sql')))),
];
});
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
exit($report['table_count'] === 24 && $report['registered_migrations'] === 6 && $report['baseline_differences'] === [] ? 0 : 1);