update
This commit is contained in:
460
test/suite/einvoice_encoding/test.enc-07.attribute-encoding.ts
Normal file
460
test/suite/einvoice_encoding/test.enc-07.attribute-encoding.ts
Normal file
@ -0,0 +1,460 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { CorpusLoader } from '../corpus.loader.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
tap.test('ENC-07: Attribute Encoding - should handle XML attribute encoding correctly', async (t) => {
|
||||
// ENC-07: Verify proper encoding of XML attributes including special chars and quotes
|
||||
// This test ensures attributes are properly encoded across different scenarios
|
||||
|
||||
const performanceTracker = new PerformanceTracker('ENC-07: Attribute Encoding');
|
||||
const corpusLoader = new CorpusLoader();
|
||||
|
||||
t.test('Basic attribute encoding', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<UBLVersionID>2.1</UBLVersionID>
|
||||
<ID schemeID="INVOICE" schemeAgencyID="6">ATTR-BASIC-001</ID>
|
||||
<IssueDate>2025-01-25</IssueDate>
|
||||
<DocumentCurrencyCode listID="ISO4217" listAgencyID="6" listVersionID="2001">EUR</DocumentCurrencyCode>
|
||||
<TaxTotal>
|
||||
<TaxAmount currencyID="EUR">19.00</TaxAmount>
|
||||
<TaxSubtotal>
|
||||
<TaxCategory>
|
||||
<ID schemeID="UNCL5305" schemeAgencyID="6">S</ID>
|
||||
<Percent>19</Percent>
|
||||
<TaxScheme>
|
||||
<ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</ID>
|
||||
</TaxScheme>
|
||||
</TaxCategory>
|
||||
</TaxSubtotal>
|
||||
</TaxTotal>
|
||||
<InvoiceLine>
|
||||
<ID>1</ID>
|
||||
<InvoicedQuantity unitCode="C62" unitCodeListID="UNECERec20">10</InvoicedQuantity>
|
||||
<LineExtensionAmount currencyID="EUR">100.00</LineExtensionAmount>
|
||||
</InvoiceLine>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(xmlContent);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Verify basic attributes are preserved
|
||||
expect(xmlString).toMatch(/schemeID\s*=\s*["']INVOICE["']/);
|
||||
expect(xmlString).toMatch(/schemeAgencyID\s*=\s*["']6["']/);
|
||||
expect(xmlString).toMatch(/listID\s*=\s*["']ISO4217["']/);
|
||||
expect(xmlString).toMatch(/listVersionID\s*=\s*["']2001["']/);
|
||||
expect(xmlString).toMatch(/currencyID\s*=\s*["']EUR["']/);
|
||||
expect(xmlString).toMatch(/unitCode\s*=\s*["']C62["']/);
|
||||
expect(xmlString).toMatch(/unitCodeListID\s*=\s*["']UNECERec20["']/);
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('basic-attributes', elapsed);
|
||||
});
|
||||
|
||||
t.test('Attributes with special characters', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<UBLVersionID>2.1</UBLVersionID>
|
||||
<ID>ATTR-SPECIAL-001</ID>
|
||||
<Note languageID="de-DE" encoding="UTF-8">Rechnung für Bücher & Zeitschriften</Note>
|
||||
<PaymentMeans>
|
||||
<PaymentMeansCode name="Überweisung (Bank & SEPA)">30</PaymentMeansCode>
|
||||
<PaymentID reference="Order <2025-001>">PAY-123</PaymentID>
|
||||
<PayeeFinancialAccount>
|
||||
<Name type="IBAN & BIC">DE89 3704 0044 0532 0130 00</Name>
|
||||
<FinancialInstitutionBranch>
|
||||
<Name branch="München "Zentrum"">Sparkasse</Name>
|
||||
</FinancialInstitutionBranch>
|
||||
</PayeeFinancialAccount>
|
||||
</PaymentMeans>
|
||||
<AllowanceCharge>
|
||||
<ChargeIndicator>false</ChargeIndicator>
|
||||
<AllowanceChargeReason code="95" description="Discount for > 100€ orders">Volume discount</AllowanceChargeReason>
|
||||
<Amount currencyID="EUR" percentage="5%" calculation="100 * 0.05">5.00</Amount>
|
||||
</AllowanceCharge>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(xmlContent);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Verify special characters in attributes are properly escaped
|
||||
expect(xmlString).toMatch(/name\s*=\s*["']Überweisung \(Bank & SEPA\)["']/);
|
||||
expect(xmlString).toMatch(/reference\s*=\s*["']Order <2025-001>["']/);
|
||||
expect(xmlString).toMatch(/type\s*=\s*["']IBAN & BIC["']/);
|
||||
expect(xmlString).toMatch(/branch\s*=\s*["']München ("|")Zentrum("|")["']/);
|
||||
expect(xmlString).toMatch(/description\s*=\s*["']Discount for > 100€ orders["']/);
|
||||
expect(xmlString).toMatch(/percentage\s*=\s*["']5%["']/);
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('special-char-attributes', elapsed);
|
||||
});
|
||||
|
||||
t.test('Quote handling in attributes', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<UBLVersionID>2.1</UBLVersionID>
|
||||
<ID>ATTR-QUOTES-001</ID>
|
||||
<Note title='Single quotes with "double quotes" inside'>Test note</Note>
|
||||
<AdditionalDocumentReference>
|
||||
<ID description="Product "Premium" edition">DOC-001</ID>
|
||||
<DocumentDescription title="User's guide">Manual for "advanced" users</DocumentDescription>
|
||||
<Attachment>
|
||||
<ExternalReference>
|
||||
<URI scheme="http" description='Link to "official" site'>http://example.com/doc?id=123&type="pdf"</URI>
|
||||
</ExternalReference>
|
||||
</Attachment>
|
||||
</AdditionalDocumentReference>
|
||||
<InvoiceLine>
|
||||
<Item>
|
||||
<Name type='"Special" product'>Item with quotes</Name>
|
||||
<Description note="Contains both 'single' and "double" quotes">Complex quoting test</Description>
|
||||
<AdditionalItemProperty>
|
||||
<Name>Quote test</Name>
|
||||
<Value type="text" format='He said: "It\'s working!"'>Quoted value</Value>
|
||||
</AdditionalItemProperty>
|
||||
</Item>
|
||||
</InvoiceLine>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(xmlContent);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Verify quote handling - implementation may use different strategies
|
||||
// Either escape quotes or switch quote style
|
||||
expect(xmlString).toBeTruthy();
|
||||
|
||||
// Should contain the attribute values somehow
|
||||
expect(xmlString).toMatch(/Single quotes with .*double quotes.* inside/);
|
||||
expect(xmlString).toMatch(/Product .*Premium.* edition/);
|
||||
expect(xmlString).toMatch(/User.*s guide/);
|
||||
expect(xmlString).toMatch(/Special.*product/);
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('quote-attributes', elapsed);
|
||||
});
|
||||
|
||||
t.test('International characters in attributes', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<UBLVersionID>2.1</UBLVersionID>
|
||||
<ID>ATTR-INTL-001</ID>
|
||||
<Note languageID="multi" region="Europa/歐洲/यूरोप">International attributes</Note>
|
||||
<AccountingSupplierParty>
|
||||
<Party>
|
||||
<PartyName>
|
||||
<Name tradingName="Société Générale" localName="ソシエテ・ジェネラル">SG Group</Name>
|
||||
</PartyName>
|
||||
<PostalAddress>
|
||||
<StreetName type="Avenue/大道/एवेन्यू">Champs-Élysées</StreetName>
|
||||
<CityName region="Île-de-France">Paris</CityName>
|
||||
<Country>
|
||||
<IdentificationCode listName="ISO 3166-1 α2">FR</IdentificationCode>
|
||||
<Name language="fr-FR">République française</Name>
|
||||
</Country>
|
||||
</PostalAddress>
|
||||
</Party>
|
||||
</AccountingSupplierParty>
|
||||
<PaymentTerms>
|
||||
<Note terms="30 días/天/दिन" currency="€/¥/₹">Multi-currency payment</Note>
|
||||
</PaymentTerms>
|
||||
<InvoiceLine>
|
||||
<Item>
|
||||
<Name category="Bücher/书籍/पुस्तकें">International Books</Name>
|
||||
<Description author="François Müller (佛朗索瓦·穆勒)">Multilingual content</Description>
|
||||
</Item>
|
||||
</InvoiceLine>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(xmlContent);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Verify international characters in attributes
|
||||
expect(xmlString).toContain('Europa/歐洲/यूरोप');
|
||||
expect(xmlString).toContain('Société Générale');
|
||||
expect(xmlString).toContain('ソシエテ・ジェネラル');
|
||||
expect(xmlString).toContain('Avenue/大道/एवेन्यू');
|
||||
expect(xmlString).toContain('Île-de-France');
|
||||
expect(xmlString).toContain('α2'); // Greek alpha
|
||||
expect(xmlString).toContain('République française');
|
||||
expect(xmlString).toContain('30 días/天/दिन');
|
||||
expect(xmlString).toContain('€/¥/₹');
|
||||
expect(xmlString).toContain('Bücher/书籍/पुस्तकें');
|
||||
expect(xmlString).toContain('佛朗索瓦·穆勒');
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('intl-attributes', elapsed);
|
||||
});
|
||||
|
||||
t.test('Empty and whitespace attributes', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<UBLVersionID>2.1</UBLVersionID>
|
||||
<ID>ATTR-WHITESPACE-001</ID>
|
||||
<Note title="" language="">Empty attributes</Note>
|
||||
<DocumentReference>
|
||||
<ID schemeID=" " schemeAgencyID=" ">REF-001</ID>
|
||||
<DocumentDescription prefix=" " suffix=" "> Trimmed content </DocumentDescription>
|
||||
</DocumentReference>
|
||||
<PaymentMeans>
|
||||
<PaymentID reference="
|
||||
multiline
|
||||
reference
|
||||
">PAY-001</PaymentID>
|
||||
<InstructionNote format=" preserved spaces ">Note with spaces</InstructionNote>
|
||||
</PaymentMeans>
|
||||
<InvoiceLine>
|
||||
<LineExtensionAmount currencyID="EUR" decimals="" symbol="€">100.00</LineExtensionAmount>
|
||||
<Item>
|
||||
<Description short=" " long=" ">Item description</Description>
|
||||
</Item>
|
||||
</InvoiceLine>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(xmlContent);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Verify empty attributes are preserved
|
||||
expect(xmlString).toMatch(/title\s*=\s*["'](\s*)["']/);
|
||||
expect(xmlString).toMatch(/language\s*=\s*["'](\s*)["']/);
|
||||
|
||||
// Whitespace handling may vary
|
||||
expect(xmlString).toContain('schemeID=');
|
||||
expect(xmlString).toContain('reference=');
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('whitespace-attributes', elapsed);
|
||||
});
|
||||
|
||||
t.test('Numeric and boolean attribute values', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<UBLVersionID>2.1</UBLVersionID>
|
||||
<ID>ATTR-NUMERIC-001</ID>
|
||||
<AllowanceCharge>
|
||||
<ChargeIndicator>true</ChargeIndicator>
|
||||
<SequenceNumeric>1</SequenceNumeric>
|
||||
<Amount currencyID="EUR" decimals="2" precision="0.01">19.99</Amount>
|
||||
<BaseAmount currencyID="EUR" percentage="19.5" factor="0.195">100.00</BaseAmount>
|
||||
</AllowanceCharge>
|
||||
<TaxTotal>
|
||||
<TaxAmount currencyID="EUR" rate="19" rateType="percent">19.00</TaxAmount>
|
||||
<TaxSubtotal>
|
||||
<TaxableAmount currencyID="EUR" rounded="false">100.00</TaxableAmount>
|
||||
<TaxCategory>
|
||||
<ID>S</ID>
|
||||
<Percent format="decimal">19.0</Percent>
|
||||
<TaxExemptionReason code="0" active="true">Not exempt</TaxExemptionReason>
|
||||
</TaxCategory>
|
||||
</TaxSubtotal>
|
||||
</TaxTotal>
|
||||
<InvoiceLine>
|
||||
<ID sequence="001" index="0">1</ID>
|
||||
<InvoicedQuantity unitCode="C62" value="10.0" isInteger="true">10</InvoicedQuantity>
|
||||
<Price>
|
||||
<PriceAmount currencyID="EUR" negative="false">10.00</PriceAmount>
|
||||
<BaseQuantity unitCode="C62" default="1">1</BaseQuantity>
|
||||
</Price>
|
||||
</InvoiceLine>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(xmlContent);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Verify numeric and boolean attributes
|
||||
expect(xmlString).toMatch(/decimals\s*=\s*["']2["']/);
|
||||
expect(xmlString).toMatch(/precision\s*=\s*["']0\.01["']/);
|
||||
expect(xmlString).toMatch(/percentage\s*=\s*["']19\.5["']/);
|
||||
expect(xmlString).toMatch(/factor\s*=\s*["']0\.195["']/);
|
||||
expect(xmlString).toMatch(/rate\s*=\s*["']19["']/);
|
||||
expect(xmlString).toMatch(/rounded\s*=\s*["']false["']/);
|
||||
expect(xmlString).toMatch(/active\s*=\s*["']true["']/);
|
||||
expect(xmlString).toMatch(/sequence\s*=\s*["']001["']/);
|
||||
expect(xmlString).toMatch(/index\s*=\s*["']0["']/);
|
||||
expect(xmlString).toMatch(/isInteger\s*=\s*["']true["']/);
|
||||
expect(xmlString).toMatch(/negative\s*=\s*["']false["']/);
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('numeric-boolean-attributes', elapsed);
|
||||
});
|
||||
|
||||
t.test('Namespace-prefixed attributes', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice
|
||||
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
|
||||
xsi:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 Invoice.xsd">
|
||||
<UBLVersionID>2.1</UBLVersionID>
|
||||
<ID>ATTR-NS-PREFIX-001</ID>
|
||||
<ProfileID xsi:type="string">urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</ProfileID>
|
||||
<AdditionalDocumentReference>
|
||||
<ID>DOC-001</ID>
|
||||
<Attachment>
|
||||
<ExternalReference>
|
||||
<URI xlink:type="simple" xlink:href="http://example.com/doc.pdf" xlink:title="Invoice Documentation">http://example.com/doc.pdf</URI>
|
||||
</ExternalReference>
|
||||
<EmbeddedDocumentBinaryObject
|
||||
mimeCode="application/pdf"
|
||||
encodingCode="base64"
|
||||
filename="invoice.pdf"
|
||||
ds:algorithm="SHA256">
|
||||
JVBERi0xLjQKJeLjz9MKNCAwIG9iago=
|
||||
</EmbeddedDocumentBinaryObject>
|
||||
</Attachment>
|
||||
</AdditionalDocumentReference>
|
||||
<Signature>
|
||||
<ID>SIG-001</ID>
|
||||
<SignatureMethod ds:Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256">RSA-SHA256</SignatureMethod>
|
||||
</Signature>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(xmlContent);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Verify namespace-prefixed attributes
|
||||
expect(xmlString).toContain('xsi:schemaLocation=');
|
||||
expect(xmlString).toContain('xsi:type=');
|
||||
expect(xmlString).toContain('xlink:type=');
|
||||
expect(xmlString).toContain('xlink:href=');
|
||||
expect(xmlString).toContain('xlink:title=');
|
||||
expect(xmlString).toContain('ds:algorithm=');
|
||||
expect(xmlString).toContain('ds:Algorithm=');
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('ns-prefixed-attributes', elapsed);
|
||||
});
|
||||
|
||||
t.test('Corpus attribute analysis', async () => {
|
||||
const startTime = performance.now();
|
||||
let processedCount = 0;
|
||||
const attributeStats = {
|
||||
totalAttributes: 0,
|
||||
escapedAttributes: 0,
|
||||
unicodeAttributes: 0,
|
||||
numericAttributes: 0,
|
||||
emptyAttributes: 0,
|
||||
commonAttributes: new Map<string, number>()
|
||||
};
|
||||
|
||||
const files = await corpusLoader.getAllFiles();
|
||||
const xmlFiles = files.filter(f => f.endsWith('.xml'));
|
||||
|
||||
// Analyze attribute usage in corpus
|
||||
const sampleSize = Math.min(80, xmlFiles.length);
|
||||
const sample = xmlFiles.slice(0, sampleSize);
|
||||
|
||||
for (const file of sample) {
|
||||
try {
|
||||
const content = await corpusLoader.readFile(file);
|
||||
let xmlString: string;
|
||||
|
||||
if (Buffer.isBuffer(content)) {
|
||||
xmlString = content.toString('utf8');
|
||||
} else {
|
||||
xmlString = content;
|
||||
}
|
||||
|
||||
// Count attributes
|
||||
const attrMatches = xmlString.match(/\s(\w+(?::\w+)?)\s*=\s*["'][^"']*["']/g);
|
||||
if (attrMatches) {
|
||||
attributeStats.totalAttributes += attrMatches.length;
|
||||
|
||||
attrMatches.forEach(attr => {
|
||||
// Check for escaped content
|
||||
if (attr.includes('&') || attr.includes('<') || attr.includes('>') ||
|
||||
attr.includes('"') || attr.includes(''')) {
|
||||
attributeStats.escapedAttributes++;
|
||||
}
|
||||
|
||||
// Check for Unicode
|
||||
if (/[^\x00-\x7F]/.test(attr)) {
|
||||
attributeStats.unicodeAttributes++;
|
||||
}
|
||||
|
||||
// Check for numeric values
|
||||
if (/=\s*["']\d+(?:\.\d+)?["']/.test(attr)) {
|
||||
attributeStats.numericAttributes++;
|
||||
}
|
||||
|
||||
// Check for empty values
|
||||
if (/=\s*["']\s*["']/.test(attr)) {
|
||||
attributeStats.emptyAttributes++;
|
||||
}
|
||||
|
||||
// Extract attribute name
|
||||
const nameMatch = attr.match(/(\w+(?::\w+)?)\s*=/);
|
||||
if (nameMatch) {
|
||||
const attrName = nameMatch[1];
|
||||
attributeStats.commonAttributes.set(
|
||||
attrName,
|
||||
(attributeStats.commonAttributes.get(attrName) || 0) + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
} catch (error) {
|
||||
console.log(`Attribute parsing issue in ${file}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Attribute corpus analysis (${processedCount} files):`);
|
||||
console.log(`- Total attributes: ${attributeStats.totalAttributes}`);
|
||||
console.log(`- Escaped attributes: ${attributeStats.escapedAttributes}`);
|
||||
console.log(`- Unicode attributes: ${attributeStats.unicodeAttributes}`);
|
||||
console.log(`- Numeric attributes: ${attributeStats.numericAttributes}`);
|
||||
console.log(`- Empty attributes: ${attributeStats.emptyAttributes}`);
|
||||
|
||||
const topAttributes = Array.from(attributeStats.commonAttributes.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10);
|
||||
console.log('Top 10 attribute names:', topAttributes);
|
||||
|
||||
expect(processedCount).toBeGreaterThan(0);
|
||||
expect(attributeStats.totalAttributes).toBeGreaterThan(0);
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('corpus-attributes', elapsed);
|
||||
});
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
|
||||
// Performance assertions
|
||||
const avgTime = performanceTracker.getAverageTime();
|
||||
expect(avgTime).toBeLessThan(120); // Attribute operations should be reasonably fast
|
||||
});
|
||||
|
||||
tap.start();
|
Reference in New Issue
Block a user